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
83 changes: 83 additions & 0 deletions codegen/functional/mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

import type { EsApiDefinition } from '../../src/es/types.ts'
import { resolveInput } from '../../src/es/types.ts'
import { extractSchemaArgs } from '../../src/lib/schema-args.ts'
import type { SchemaArgDefinition } from '../../src/lib/schema-args.ts'

/**
* Result of mapping a YAML dot-notation action to a CLI command.
* Contains the CLI arguments needed to invoke the command.
*/
export interface MappedAction {
/** CLI args: ['es', namespace?, name, ...flags] */
cliArgs: string[]
/** true if the action accepts a request body */
hasBody: boolean
}

/**
* Builds a lookup from YAML dot-notation action names to EsApiDefinitions.
*
* YAML uses `namespace.name` (e.g. "indices.create") or just `name` (e.g. "get").
* Definitions with `namespace` are keyed as `namespace.name`.
* Definitions without `namespace` are keyed as just `name`.
*/
export function buildActionMap (definitions: EsApiDefinition[]): Map<string, EsApiDefinition> {
const map = new Map<string, EsApiDefinition>()
for (const def of definitions) {
const key = def.namespace != null ? `${def.namespace}.${def.name}` : def.name
map.set(key, def)
}
return map
}

/**
* Maps a YAML test action to CLI arguments.
*
* @param action - dot-notation action name (e.g. "indices.create", "get")
* @param params - YAML action parameters (path + query params, excluding body)
* @param actionMap - lookup map from buildActionMap
* @returns MappedAction with CLI args, or null if the action isn't registered
*/
export function mapAction (
action: string,
params: Record<string, unknown>,
actionMap: Map<string, EsApiDefinition>
): MappedAction | null {
const def = actionMap.get(action)
if (def == null) return null

const args: string[] = ['es']
if (def.namespace != null) args.push(def.namespace)
args.push(def.name)

const schemaArgs = def.input != null
? extractSchemaArgs(resolveInput(def.input))
: []

const bodyFields = new Set(
schemaArgs.filter((a) => a.foundIn === 'body').map((a) => a.schemaKey)
)

const argsByKey = new Map<string, SchemaArgDefinition>()
for (const arg of schemaArgs) {
argsByKey.set(arg.schemaKey, arg)
}

for (const [key, value] of Object.entries(params)) {
if (bodyFields.has(key)) continue
if (key === 'ignore') continue

const argDef = argsByKey.get(key)
const flag = argDef != null ? argDef.cliFlag : key.replace(/_/g, '-')

args.push(`--${flag}`, String(value))
}

const hasBody = schemaArgs.some((a) => a.foundIn === 'body')
return { cliArgs: args, hasBody }
}
185 changes: 185 additions & 0 deletions codegen/functional/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { parseAllDocuments } from 'yaml'
import type {
TestFile, Requires, TestSection, Step,
DoStep, SetStep, MatchStep, IsTrueStep, IsFalseStep, LengthStep,
GtStep, GteStep, LtStep, LteStep, ContainsStep
} from './types.ts'

const RESERVED_KEYS = new Set(['requires', 'setup', 'teardown'])

/**
* Parse a YAML test file from elasticsearch-clients-tests into a typed AST.
*
* Each file is a multi-document YAML stream separated by `---`.
* Documents are: requires, optional setup, optional teardown, then named test sections.
*/
export function parseTestFile (yamlContent: string, sourceFile: string): TestFile {
const docs = parseAllDocuments(yamlContent)
const sections: Record<string, unknown>[] = docs
.map((doc) => doc.toJSON() as Record<string, unknown> | null)
.filter((v): v is Record<string, unknown> => v != null)

let requires: Requires = { serverless: false, stack: false }
let setup: Step[] = []
let teardown: Step[] = []
const tests: TestSection[] = []

for (const section of sections) {
if ('requires' in section) {
const r = section.requires as Record<string, unknown>
requires = {
serverless: r.serverless === true,
stack: r.stack === true
}
continue
}

if ('setup' in section) {
setup = parseSteps(section.setup as unknown[])
continue
}

if ('teardown' in section) {
teardown = parseSteps(section.teardown as unknown[])
continue
}

// remaining keys are named test sections
for (const [name, steps] of Object.entries(section)) {
if (RESERVED_KEYS.has(name)) continue
tests.push({
name,
steps: parseSteps(steps as unknown[])
})
}
}

return { sourceFile, requires, setup, teardown, tests }
}

/**
* Returns true if the test file targets serverless.
*/
export function isServerless (file: TestFile): boolean {
return file.requires.serverless
}

// ---------------------------------------------------------------------------
// Step parsing
// ---------------------------------------------------------------------------

function parseSteps (raw: unknown[]): Step[] {
const steps: Step[] = []
for (const item of raw) {
if (item == null || typeof item !== 'object') continue
const obj = item as Record<string, unknown>

if ('do' in obj) {
steps.push(parseDo(obj.do as Record<string, unknown>))
} else if ('set' in obj) {
steps.push(parseSet(obj.set as Record<string, unknown>))
} else if ('match' in obj) {
steps.push(parseMatch(obj.match as Record<string, unknown>))
} else if ('is_true' in obj) {
steps.push(parseIsTrue(obj.is_true))
} else if ('is_false' in obj) {
steps.push(parseIsFalse(obj.is_false))
} else if ('length' in obj) {
steps.push(parseLength(obj.length as Record<string, unknown>))
} else if ('gt' in obj) {
steps.push(parseComparison('gt', obj.gt as Record<string, unknown>))
} else if ('gte' in obj) {
steps.push(parseComparison('gte', obj.gte as Record<string, unknown>))
} else if ('lt' in obj) {
steps.push(parseComparison('lt', obj.lt as Record<string, unknown>))
} else if ('lte' in obj) {
steps.push(parseComparison('lte', obj.lte as Record<string, unknown>))
} else if ('contains' in obj) {
steps.push(parseContains(obj.contains as Record<string, unknown>))
} else if ('skip' in obj) {
steps.push({ kind: 'skip' })
}
}
return steps
}

/** Keys in a `do` block that are metadata, not the API action. */
const DO_META_KEYS = new Set(['catch', 'headers', 'ignore'])

function parseDo (raw: Record<string, unknown>): DoStep {
let catchValue: string | undefined
if ('catch' in raw) {
catchValue = String(raw.catch)
}

const headers = raw.headers as Record<string, string> | undefined
const ignore = raw.ignore as number | number[] | undefined

// The action key is the first key that isn't metadata
let action = ''
let actionValue: Record<string, unknown> = {}
for (const [key, val] of Object.entries(raw)) {
if (DO_META_KEYS.has(key)) continue
action = key
actionValue = (val != null && typeof val === 'object' && !Array.isArray(val))
? val as Record<string, unknown>
: {}
break
}

const { body, ...params } = actionValue

const step: DoStep = { kind: 'do', action, params }
if (body !== undefined) step.body = body
if (catchValue !== undefined) step.catch = catchValue
if (headers !== undefined) step.headers = headers
if (ignore !== undefined) {
step.ignore = Array.isArray(ignore) ? ignore : [ignore]
}
return step
}

function parseSet (raw: Record<string, unknown>): SetStep {
const assignments: Record<string, string> = {}
for (const [responsePath, varName] of Object.entries(raw)) {
assignments[responsePath] = String(varName)
}
return { kind: 'set', assignments }
}

function parseMatch (raw: Record<string, unknown>): MatchStep {
return { kind: 'match', assertions: { ...raw } }
}

function parseIsTrue (raw: unknown): IsTrueStep {
return { kind: 'is_true', field: String(raw ?? '') }
}

function parseIsFalse (raw: unknown): IsFalseStep {
return { kind: 'is_false', field: String(raw ?? '') }
}

function parseLength (raw: Record<string, unknown>): LengthStep {
const assertions: Record<string, number> = {}
for (const [path, len] of Object.entries(raw)) {
assertions[path] = Number(len)
}
return { kind: 'length', assertions }
}

function parseComparison (kind: 'gt' | 'gte' | 'lt' | 'lte', raw: Record<string, unknown>): GtStep | GteStep | LtStep | LteStep {
const assertions: Record<string, number> = {}
for (const [path, val] of Object.entries(raw)) {
assertions[path] = Number(val)
}
return { kind, assertions }
}

function parseContains (raw: Record<string, unknown>): ContainsStep {
return { kind: 'contains', assertions: { ...raw } }
}
126 changes: 126 additions & 0 deletions codegen/functional/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

// ---------------------------------------------------------------------------
// Parsed AST for elasticsearch-clients-tests YAML files
// ---------------------------------------------------------------------------

/** Top-level structure of a parsed YAML test file. */
export interface TestFile {
/** Source file path (relative to tests dir) */
sourceFile: string
requires: Requires
setup: Step[]
teardown: Step[]
tests: TestSection[]
}

export interface Requires {
serverless: boolean
stack: boolean
}

/** A named test section (e.g. "get", "Basic bulk operation"). */
export interface TestSection {
name: string
steps: Step[]
}

// ---------------------------------------------------------------------------
// Steps — ordered operations within a test section / setup / teardown
// ---------------------------------------------------------------------------

export type Step =
| DoStep
| SetStep
| MatchStep
| IsTrueStep
| IsFalseStep
| LengthStep
| GtStep
| GteStep
| LtStep
| LteStep
| ContainsStep
| SkipStep

export interface DoStep {
kind: 'do'
/** dot-notation action name (e.g. "indices.create", "get", "bulk") */
action: string
/** parameters for the action (everything except "body", "catch", "headers", "ignore") */
params: Record<string, unknown>
/** request body, if present */
body?: unknown
/** expected error type — when present the action is expected to fail */
catch?: string
/** custom HTTP headers for this request */
headers?: Record<string, string>
/** HTTP status codes to ignore (e.g. [404] for teardown cleanup) */
ignore?: number[]
}

export interface SetStep {
kind: 'set'
/** Maps response path -> variable name (e.g. { "_id": "id" }) */
assignments: Record<string, string>
}

export interface MatchStep {
kind: 'match'
/** Maps response path -> expected value */
assertions: Record<string, unknown>
}

export interface IsTrueStep {
kind: 'is_true'
field: string
}

export interface IsFalseStep {
kind: 'is_false'
field: string
}

export interface LengthStep {
kind: 'length'
/** Maps response path -> expected length */
assertions: Record<string, number>
}

export interface GtStep {
kind: 'gt'
/** Maps response path -> value that response must be greater than */
assertions: Record<string, number>
}

export interface GteStep {
kind: 'gte'
/** Maps response path -> value that response must be greater than or equal to */
assertions: Record<string, number>
}

export interface LtStep {
kind: 'lt'
/** Maps response path -> value that response must be less than */
assertions: Record<string, number>
}

export interface LteStep {
kind: 'lte'
/** Maps response path -> value that response must be less than or equal to */
assertions: Record<string, number>
}

export interface ContainsStep {
kind: 'contains'
/** Maps response path -> value that the array must contain */
assertions: Record<string, unknown>
}

/** No-op — the skip action is parsed but does not produce output. */
export interface SkipStep {
kind: 'skip'
}
Loading