From f2eb4b35ce6f5b24ef9729172ebf062e36dcbb68 Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Wed, 6 May 2026 15:47:31 +0800 Subject: [PATCH 1/6] feat(server): validate api --- apps/cli/e2e/server/basic.e2e-spec.ts | 88 +++++++++++ .../e2e/server/sync-standalone.e2e-spec.ts | 17 ++ apps/cli/e2e/support/utils.ts | 6 + apps/cli/src/server/index.ts | 2 + apps/cli/src/server/schema.ts | 19 +++ apps/cli/src/server/validate.ts | 147 ++++++++++++++++++ 6 files changed, 279 insertions(+) create mode 100644 apps/cli/e2e/server/sync-standalone.e2e-spec.ts create mode 100644 apps/cli/src/server/validate.ts diff --git a/apps/cli/e2e/server/basic.e2e-spec.ts b/apps/cli/e2e/server/basic.e2e-spec.ts index 768ed7f0..824c4d53 100644 --- a/apps/cli/e2e/server/basic.e2e-spec.ts +++ b/apps/cli/e2e/server/basic.e2e-spec.ts @@ -264,6 +264,94 @@ describe('Server - Basic', () => { await server.stop(); }); + it('test validate with empty config', async () => { + const { status, body } = await request(server.TEST_ONLY_getExpress()) + .put('/validate') + .send({ + task: { + opts: { + backend: 'mock', + server: 'http://1.1.1.1:3000', + token: 'mock', + cacheKey: 'default', + }, + config: {}, + }, + }); + + expect(status).toEqual(200); + expect(body.success).toEqual(true); + expect(body.errors).toEqual([]); + }); + + it('test validate with config', async () => { + const config = { + consumers: [ + { + username: 'test-consumer', + plugins: { 'limit-count': { count: 10, time_window: 60 } }, + }, + ], + } as ADCSDK.Configuration; + const { status, body } = await request(server.TEST_ONLY_getExpress()) + .put('/validate') + .send({ + task: { + opts: { + backend: 'mock', + server: 'http://1.1.1.1:3000', + token: 'mock', + cacheKey: 'default', + }, + config, + }, + }); + + expect(status).toEqual(200); + expect(body.success).toEqual(true); + expect(body.errors).toEqual([]); + }); + + it('test validate with invalid input', async () => { + const { status, body } = await request(server.TEST_ONLY_getExpress()) + .put('/validate') + .send({ + task: { + opts: { + server: 'http://1.1.1.1:3000', + token: 'mock', + cacheKey: 'default', + }, + config: {}, + }, + }); + + expect(status).toEqual(400); + expect(body.success).toEqual(false); + }); + + it('test validate with lint failure', async () => { + const { status, body } = await request(server.TEST_ONLY_getExpress()) + .put('/validate') + .send({ + task: { + opts: { + backend: 'mock', + server: 'http://1.1.1.1:3000', + token: 'mock', + lint: true, + cacheKey: 'default', + }, + config: { + invalid_key: {}, + }, + }, + }); + + expect(status).toEqual(400); + expect(body.success).toEqual(false); + }); + it('test status listen', async () => { const server = new ADCServer({ listen: new URL(`http://127.0.0.1:3000`), diff --git a/apps/cli/e2e/server/sync-standalone.e2e-spec.ts b/apps/cli/e2e/server/sync-standalone.e2e-spec.ts new file mode 100644 index 00000000..db32a331 --- /dev/null +++ b/apps/cli/e2e/server/sync-standalone.e2e-spec.ts @@ -0,0 +1,17 @@ +import { BackendAPISIXStandalone } from '@api7/adc-backend-apisix-standalone'; +import * as ADCSDK from '@api7/adc-sdk'; + +import { ADCServer } from '../../src/server'; + +describe('Server - Sync (Standalone)', () => { + let backend: ADCSDK.Backend; + let server: ADCServer; + + beforeAll(async () => { + backend = new BackendAPISIXStandalone(); + server = new ADCServer({ + listen: new URL('http://127.0.1:3000'), + listenStatus: 3001, + }); + }); +}); diff --git a/apps/cli/e2e/support/utils.ts b/apps/cli/e2e/support/utils.ts index 863b32de..0cc5c6db 100644 --- a/apps/cli/e2e/support/utils.ts +++ b/apps/cli/e2e/support/utils.ts @@ -47,6 +47,12 @@ export const mockBackend = (): ADCSDK.Backend => { ), ); } + public validate(events: Array) { + return of({ + success: true, + errors: [], + } as ADCSDK.BackendValidateResult); + } public on() { return new Subscription(); } diff --git a/apps/cli/src/server/index.ts b/apps/cli/src/server/index.ts index 1a982ebd..5f8f3296 100644 --- a/apps/cli/src/server/index.ts +++ b/apps/cli/src/server/index.ts @@ -6,6 +6,7 @@ import * as https from 'node:https'; import { loggerMiddleware } from './logger'; import { syncHandler } from './sync'; +import { validateHandler } from './validate'; interface ADCServerOptions { listen: URL; @@ -29,6 +30,7 @@ export class ADCServer { this.express.use(express.json({ limit: '100mb' })); this.express.use(loggerMiddleware); this.express.put('/sync', syncHandler); + this.express.put('/validate', validateHandler); this.expressStatus.get('/healthz/ready', (_, res) => res.status(200).send('OK'), ); diff --git a/apps/cli/src/server/schema.ts b/apps/cli/src/server/schema.ts index 730b4dd3..339fce30 100644 --- a/apps/cli/src/server/schema.ts +++ b/apps/cli/src/server/schema.ts @@ -19,3 +19,22 @@ export const SyncInput = z.strictObject({ task: SyncTask, }); export type SyncInputType = z.infer; + +const ValidateTask = z.strictObject({ + opts: z.looseObject({ + backend: z.string().min(1), + server: z.union([z.url().min(1), z.array(z.url().min(1))]), + token: z.string().min(1), + lint: z.boolean().optional().default(true), + includeResourceType: z.array(z.enum(ADCSDK.ResourceType)).optional(), + excludeResourceType: z.array(z.enum(ADCSDK.ResourceType)).optional(), + labelSelector: z.record(z.string(), z.string()).optional(), + cacheKey: z.string(), + }), + config: z.looseObject({}), +}); + +export const ValidateInput = z.strictObject({ + task: ValidateTask, +}); +export type ValidateInputType = z.infer; diff --git a/apps/cli/src/server/validate.ts b/apps/cli/src/server/validate.ts new file mode 100644 index 00000000..4b3c6391 --- /dev/null +++ b/apps/cli/src/server/validate.ts @@ -0,0 +1,147 @@ +import { DifferV3 } from '@api7/adc-differ'; +import * as ADCSDK from '@api7/adc-sdk'; +import { HttpAgent, HttpOptions, HttpsAgent } from 'agentkeepalive'; +import type { RequestHandler } from 'express'; +import { toString } from 'lodash-es'; +import { lastValueFrom } from 'rxjs'; + +import { fillLabels, filterResourceType, loadBackend } from '../command/utils'; +import { check } from '../linter'; +import { logger } from './logger'; +import { ValidateInput, type ValidateInputType } from './schema'; + +// create connection pool +const keepAlive: HttpOptions = { + keepAlive: true, + maxSockets: 256, // per host + maxFreeSockets: 16, // per host free + freeSocketTimeout: + parseInt(process.env.ADC_INGRESS_FREE_SOCKET_TIMEOUT) || 50000, +}; +const httpAgent = new HttpAgent(keepAlive); + +//TODO: dynamic rejectUnauthorized and support mTLS +const httpsAgent = new HttpsAgent({ + rejectUnauthorized: true, + ...keepAlive, +}); +const httpsInsecureAgent = new HttpsAgent({ + rejectUnauthorized: false, + ...keepAlive, +}); + +export const validateHandler: RequestHandler< + unknown, + unknown, + ValidateInputType +> = async (req, res) => { + try { + const parsedInput = ValidateInput.safeParse(req.body); + if (!parsedInput.success) + return res.status(400).json({ + success: false, + message: parsedInput.error.message, + errors: parsedInput.error.issues, + }); + const { task } = parsedInput.data; + + // load local configuration and filter resource types + const local = filterResourceType( + task.config, + task.opts.includeResourceType, + task.opts.excludeResourceType, + ) as ADCSDK.InternalConfiguration; + + // optional lint + if (task.opts.lint) { + const result = check(local); + if (!result.success) + return res.status(400).json({ + success: false, + message: result.error.message, + errors: result.error.issues, + }); + } + fillLabels(local, task.opts.labelSelector); + + // initialize backend + const backend = loadBackend(task.opts.backend, { + ...task.opts, + server: Array.isArray(task.opts.server) + ? task.opts.server.join(',') + : task.opts.server, + httpAgent, + httpsAgent: task.opts.tlsSkipVerify ? httpsInsecureAgent : httpsAgent, + }); + + backend.on('AXIOS_DEBUG', ({ description, response }) => + logger.log({ + level: 'debug', + message: description, + request: { + method: response.config.method, + url: response.config.url, + headers: response.config.headers, + data: response.config.data, + }, + response: { + status: response.status, + headers: response.headers, + data: response.data, + }, + requestId: req.requestId, + }), + ); + + // generate events by diffing against an empty remote config + const events = DifferV3.diff( + local, + {} as ADCSDK.InternalConfiguration, + await backend.defaultValue(), + undefined, + { + log: (message: string) => + logger.log({ level: 'debug', message, requestId: req.requestId }), + debug: (logEntry) => + logger.log({ level: 'debug', ...logEntry, requestId: req.requestId }), + }, + ); + + // check if backend supports validate + if (!backend.validate) + return res.status(400).json({ + success: false, + message: 'Validate is not supported by the current backend.', + errors: [], + }); + + // execute validation + const result = await lastValueFrom(backend.validate(events)); + + logger.log({ + level: 'debug', + message: 'validate finished', + success: result.success, + errors: result.errors, + requestId: req.requestId, + }); + + res.status(200).json({ + success: result.success, + ...(result.errorMessage ? { error_message: result.errorMessage } : {}), + errors: result.errors, + }); + } catch (err) { + logger.log({ + level: 'debug', + message: 'validate failed', + error: err, + requestId: req.requestId, + }); + res.status(500).json({ + success: false, + message: toString(err), + errors: [], + }); + } +}; From 56fe406da0aa9d64e22a00d2dbc7d46138387e2b Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Wed, 6 May 2026 15:53:03 +0800 Subject: [PATCH 2/6] fix --- apps/cli/src/server/validate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/server/validate.ts b/apps/cli/src/server/validate.ts index 4b3c6391..100b0902 100644 --- a/apps/cli/src/server/validate.ts +++ b/apps/cli/src/server/validate.ts @@ -128,7 +128,7 @@ export const validateHandler: RequestHandler< res.status(200).json({ success: result.success, - ...(result.errorMessage ? { error_message: result.errorMessage } : {}), + ...(result.errorMessage ? { message: result.errorMessage } : {}), errors: result.errors, }); } catch (err) { From 575f6942c2c75a5de346a3cdcf79249ad138bb1a Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Wed, 6 May 2026 17:17:44 +0800 Subject: [PATCH 3/6] fix --- apps/cli/e2e/server/sync-standalone.e2e-spec.ts | 17 ----------------- .../backend-apisix-standalone/tsconfig.lib.json | 11 +++++------ 2 files changed, 5 insertions(+), 23 deletions(-) delete mode 100644 apps/cli/e2e/server/sync-standalone.e2e-spec.ts diff --git a/apps/cli/e2e/server/sync-standalone.e2e-spec.ts b/apps/cli/e2e/server/sync-standalone.e2e-spec.ts deleted file mode 100644 index db32a331..00000000 --- a/apps/cli/e2e/server/sync-standalone.e2e-spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { BackendAPISIXStandalone } from '@api7/adc-backend-apisix-standalone'; -import * as ADCSDK from '@api7/adc-sdk'; - -import { ADCServer } from '../../src/server'; - -describe('Server - Sync (Standalone)', () => { - let backend: ADCSDK.Backend; - let server: ADCServer; - - beforeAll(async () => { - backend = new BackendAPISIXStandalone(); - server = new ADCServer({ - listen: new URL('http://127.0.1:3000'), - listenStatus: 3001, - }); - }); -}); diff --git a/libs/backend-apisix-standalone/tsconfig.lib.json b/libs/backend-apisix-standalone/tsconfig.lib.json index 1df15111..33606c01 100644 --- a/libs/backend-apisix-standalone/tsconfig.lib.json +++ b/libs/backend-apisix-standalone/tsconfig.lib.json @@ -12,14 +12,13 @@ "noImplicitOverride": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "types": [ - "node" - ] + "types": ["node"] }, - "include": [ - "src/**/*.ts" - ], + "include": ["src/**/*.ts"], "references": [ + { + "path": "../backend-apisix/tsconfig.lib.json" + }, { "path": "../sdk/tsconfig.lib.json" }, From 714e3cbc64c762691bd29d80022eeb37fe0247f6 Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Wed, 6 May 2026 19:43:50 +0800 Subject: [PATCH 4/6] feat: attach raw event to errors for locate resource --- libs/backend-api7/src/validator.ts | 23 +- libs/backend-api7/test/validator.spec.ts | 258 +++++++++++++++++++++ libs/backend-apisix/src/validator.ts | 30 ++- libs/backend-apisix/test/validator.spec.ts | 235 +++++++++++++++++++ libs/sdk/src/backend/index.ts | 1 + 5 files changed, 541 insertions(+), 6 deletions(-) create mode 100644 libs/backend-api7/test/validator.spec.ts create mode 100644 libs/backend-apisix/test/validator.spec.ts diff --git a/libs/backend-api7/src/validator.ts b/libs/backend-api7/src/validator.ts index b1112a3f..0aa43226 100644 --- a/libs/backend-api7/src/validator.ts +++ b/libs/backend-api7/src/validator.ts @@ -43,7 +43,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { `Validate is not supported by the current backend version (${this.opts.version}). Please upgrade to a newer version.`, ); - const { body, nameIndex } = this.buildRequestBody(events); + const { body, nameIndex, eventIndex } = this.buildRequestBody(events); try { const resp = await this.client.post( @@ -70,7 +70,12 @@ export class Validator extends ADCSDK.backend.BackendEventSource { data?.errors ?? [] ).map((e: ADCSDK.BackendValidationError) => { const name = nameIndex[e.resource_type]?.[e.index]; - return name ? { ...e, resource_name: name } : e; + const event = eventIndex[e.resource_type]?.[e.index]; + return { + ...e, + ...(name ? { resource_name: name } : {}), + ...(event ? { event } : {}), + }; }); return { success: false, @@ -85,6 +90,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { private buildRequestBody(events: Array): { body: ValidateRequestBody; nameIndex: Record; + eventIndex: Record>; } { const body: ValidateRequestBody = { routes: [], @@ -99,6 +105,10 @@ export class Validator extends ADCSDK.backend.BackendEventSource { string, string[] >; + const eventIndex = structuredClone(body) as unknown as Record< + string, + Array + >; const flat = events.filter( (e) => @@ -114,6 +124,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { this.fromADC.transformService(event.newValue as ADCSDK.Service), ); nameIndex.services.push(event.resourceName); + eventIndex.services.push(event); break; } case ADCSDK.ResourceType.ROUTE: { @@ -125,6 +136,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { ), ); nameIndex.routes.push(event.resourceName); + eventIndex.routes.push(event); break; } case ADCSDK.ResourceType.STREAM_ROUTE: { @@ -136,6 +148,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { ), ); nameIndex.stream_routes.push(event.resourceName); + eventIndex.stream_routes.push(event); break; } case ADCSDK.ResourceType.CONSUMER: { @@ -143,6 +156,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { this.fromADC.transformConsumer(event.newValue as ADCSDK.Consumer), ); nameIndex.consumers.push(event.resourceName); + eventIndex.consumers.push(event); break; } case ADCSDK.ResourceType.SSL: { @@ -151,6 +165,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { this.fromADC.transformSSL(event.newValue as ADCSDK.SSL), ); nameIndex.ssls.push(event.resourceName); + eventIndex.ssls.push(event); break; } case ADCSDK.ResourceType.GLOBAL_RULE: { @@ -158,6 +173,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { plugins: { [event.resourceId]: event.newValue }, } as unknown as typing.GlobalRule); nameIndex.global_rules.push(event.resourceName); + eventIndex.global_rules.push(event); break; } case ADCSDK.ResourceType.PLUGIN_METADATA: { @@ -168,10 +184,11 @@ export class Validator extends ADCSDK.backend.BackendEventSource { ), }); nameIndex.plugin_metadata.push(event.resourceName); + eventIndex.plugin_metadata.push(event); break; } } } - return { body, nameIndex }; + return { body, nameIndex, eventIndex }; } } diff --git a/libs/backend-api7/test/validator.spec.ts b/libs/backend-api7/test/validator.spec.ts new file mode 100644 index 00000000..1b1b7fdc --- /dev/null +++ b/libs/backend-api7/test/validator.spec.ts @@ -0,0 +1,258 @@ +/** + * Unit tests for the API7 EE Validator. + * + * NOTE: These tests are fully mocked — they test that the Validator correctly + * maps API7 validate errors back to ADC Event objects by asserting on the + * `event` field in `BackendValidationError`. No real HTTP connections are made; + * `axios.post` is stubbed via `vi.spyOn` to return a simulated 400 response. + */ +import * as ADCSDK from '@api7/adc-sdk'; +import axios, { AxiosError } from 'axios'; +import { Subject } from 'rxjs'; +import { SemVer } from 'semver'; + +import { Validator } from '../src/validator'; + +/** + * Helper to create an AxiosError that mimics a 400 validation response. + */ +const createAxios400Error = (data: Record): AxiosError => { + const error = new AxiosError( + 'Request failed with status code 400', + AxiosError.ERR_BAD_REQUEST, + ); + error.response = { + status: 400, + statusText: 'Bad Request', + headers: {}, + data, + config: {} as any, + }; + return error; +}; + +describe('API7 Validator', () => { + const createEvent = ( + resourceType: ADCSDK.ResourceType, + resourceName: string, + parentId?: string, + ): ADCSDK.Event => ({ + type: ADCSDK.EventType.CREATE, + resourceType, + resourceId: ADCSDK.utils.generateId(resourceName), + resourceName, + newValue: { name: resourceName }, + ...(parentId ? { parentId } : {}), + }); + + const defaultOpts = { + version: new SemVer('3.10.0'), + gatewayGroupId: 'default', + }; + + it('should embed event in validation errors for routes', async () => { + const parentId = ADCSDK.utils.generateId('httpbin.org'); + const events = [ + createEvent(ADCSDK.ResourceType.SERVICE, 'httpbin.org'), + createEvent(ADCSDK.ResourceType.ROUTE, 'get-anything', parentId), + ]; + + const client = axios.create(); + vi.spyOn(client, 'post').mockRejectedValue( + createAxios400Error({ + error_msg: 'Configuration validation failed', + errors: [ + { + resource_type: 'routes', + index: 0, + error: + 'does not match schema due to: Error at "/methods/0": value is not one of the allowed values', + }, + ], + }), + ); + + const validator = new Validator({ + client, + eventSubject: new Subject(), + ...defaultOpts, + }); + + const result = await validator.validate(events); + + expect(result.success).toBe(false); + expect(result.errorMessage).toBe('Configuration validation failed'); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].resource_type).toBe('routes'); + expect(result.errors[0].resource_name).toBe('get-anything'); + expect(result.errors[0].event).toBeDefined(); + expect(result.errors[0].event!.resourceType).toBe( + ADCSDK.ResourceType.ROUTE, + ); + expect(result.errors[0].event!.resourceName).toBe('get-anything'); + expect(result.errors[0].event!.parentId).toBe(parentId); + expect(result.errors[0].event!.type).toBe(ADCSDK.EventType.CREATE); + expect(result.errors[0].event!.newValue).toMatchObject({ + name: 'get-anything', + }); + }); + + it('should embed event in validation errors for services', async () => { + const events = [ + createEvent(ADCSDK.ResourceType.SERVICE, 'httpbin.org'), + createEvent(ADCSDK.ResourceType.ROUTE, 'get-anything', 'some-parent'), + ]; + + const client = axios.create(); + vi.spyOn(client, 'post').mockRejectedValue( + createAxios400Error({ + error_msg: 'Configuration validation failed', + errors: [ + { + resource_type: 'services', + index: 0, + error: 'does not match schema due to: plugins validation failed', + }, + ], + }), + ); + + const validator = new Validator({ + client, + eventSubject: new Subject(), + ...defaultOpts, + }); + + const result = await validator.validate(events); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].resource_type).toBe('services'); + expect(result.errors[0].resource_name).toBe('httpbin.org'); + expect(result.errors[0].event).toBeDefined(); + expect(result.errors[0].event!.resourceType).toBe( + ADCSDK.ResourceType.SERVICE, + ); + expect(result.errors[0].event!.resourceName).toBe('httpbin.org'); + }); + + it('should return success when no validation errors', async () => { + const events = [createEvent(ADCSDK.ResourceType.SERVICE, 'httpbin.org')]; + + const client = axios.create(); + vi.spyOn(client, 'post').mockResolvedValue({ + status: 200, + data: {}, + }); + + const validator = new Validator({ + client, + eventSubject: new Subject(), + ...defaultOpts, + }); + + const result = await validator.validate(events); + + expect(result.success).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should handle multiple errors with correct event mapping', async () => { + const parentId = ADCSDK.utils.generateId('my-service'); + const events = [ + createEvent(ADCSDK.ResourceType.SERVICE, 'my-service'), + createEvent(ADCSDK.ResourceType.ROUTE, 'route-a', parentId), + createEvent(ADCSDK.ResourceType.ROUTE, 'route-b', parentId), + createEvent(ADCSDK.ResourceType.CONSUMER, 'user1'), + ]; + + const client = axios.create(); + vi.spyOn(client, 'post').mockRejectedValue( + createAxios400Error({ + error_msg: 'Configuration validation failed', + errors: [ + { + resource_type: 'routes', + index: 0, + error: 'error on route-a', + }, + { + resource_type: 'routes', + index: 1, + error: 'error on route-b', + }, + { + resource_type: 'consumers', + index: 0, + error: 'error on user1', + }, + ], + }), + ); + + const validator = new Validator({ + client, + eventSubject: new Subject(), + ...defaultOpts, + }); + + const result = await validator.validate(events); + + expect(result.errors).toHaveLength(3); + + expect(result.errors[0].resource_name).toBe('route-a'); + expect(result.errors[0].event!.resourceName).toBe('route-a'); + expect(result.errors[0].event!.parentId).toBe(parentId); + + expect(result.errors[1].resource_name).toBe('route-b'); + expect(result.errors[1].event!.resourceName).toBe('route-b'); + expect(result.errors[1].event!.parentId).toBe(parentId); + + expect(result.errors[2].resource_name).toBe('user1'); + expect(result.errors[2].event!.resourceName).toBe('user1'); + expect(result.errors[2].event!.parentId).toBeUndefined(); + }); + + it('should handle events without matching error index gracefully', async () => { + const events = [createEvent(ADCSDK.ResourceType.SERVICE, 'my-service')]; + + const client = axios.create(); + vi.spyOn(client, 'post').mockRejectedValue( + createAxios400Error({ + errors: [ + { + resource_type: 'unknown_type', + index: 0, + error: 'some error', + }, + ], + }), + ); + + const validator = new Validator({ + client, + eventSubject: new Subject(), + ...defaultOpts, + }); + + const result = await validator.validate(events); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].event).toBeUndefined(); + }); + + it('should throw when version is below minimum', async () => { + const events = [createEvent(ADCSDK.ResourceType.SERVICE, 'my-service')]; + + const client = axios.create(); + const validator = new Validator({ + client, + eventSubject: new Subject(), + version: new SemVer('3.9.9'), + gatewayGroupId: 'default', + }); + + await expect(validator.validate(events)).rejects.toThrow( + 'Validate is not supported by the current backend version', + ); + }); +}); diff --git a/libs/backend-apisix/src/validator.ts b/libs/backend-apisix/src/validator.ts index 0d5debc8..ce4fc50a 100644 --- a/libs/backend-apisix/src/validator.ts +++ b/libs/backend-apisix/src/validator.ts @@ -35,7 +35,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { public async validate( events: Array, ): Promise { - const { body, nameIndex } = this.buildRequestBody(events); + const { body, nameIndex, eventIndex } = this.buildRequestBody(events); try { const resp = await this.client.post( @@ -67,7 +67,12 @@ export class Validator extends ADCSDK.backend.BackendEventSource { data?.errors ?? [] ).map((e: ADCSDK.BackendValidationError) => { const name = nameIndex[e.resource_type]?.[e.index]; - return name ? { ...e, resource_name: name } : e; + const event = eventIndex[e.resource_type]?.[e.index]; + return { + ...e, + ...(name ? { resource_name: name } : {}), + ...(event ? { event } : {}), + }; }); return { success: false, @@ -82,6 +87,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { private buildRequestBody(events: Array): { body: ValidateRequestBody; nameIndex: Record; + eventIndex: Record>; } { const body: ValidateRequestBody = { routes: [], @@ -103,6 +109,16 @@ export class Validator extends ADCSDK.backend.BackendEventSource { plugin_metadata: [], upstreams: [], }; + const eventIndex: Record> = { + routes: [], + services: [], + consumers: [], + ssls: [], + global_rules: [], + stream_routes: [], + plugin_metadata: [], + upstreams: [], + }; const flat = events.filter( (e) => @@ -119,9 +135,11 @@ export class Validator extends ADCSDK.backend.BackendEventSource { ); body.services.push(service); nameIndex.services.push(event.resourceName); + eventIndex.services.push(event); if (upstream) { body.upstreams.push(upstream); nameIndex.upstreams.push(event.resourceName); + eventIndex.upstreams.push(event); } break; } @@ -134,6 +152,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { ), ); nameIndex.routes.push(event.resourceName); + eventIndex.routes.push(event); break; } case ADCSDK.ResourceType.STREAM_ROUTE: { @@ -145,6 +164,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { ), ); nameIndex.stream_routes.push(event.resourceName); + eventIndex.stream_routes.push(event); break; } case ADCSDK.ResourceType.CONSUMER: { @@ -152,6 +172,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { this.fromADC.transformConsumer(event.newValue as ADCSDK.Consumer), ); nameIndex.consumers.push(event.resourceName); + eventIndex.consumers.push(event); break; } case ADCSDK.ResourceType.SSL: { @@ -160,6 +181,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { this.fromADC.transformSSL(event.newValue as ADCSDK.SSL), ); nameIndex.ssls.push(event.resourceName); + eventIndex.ssls.push(event); break; } case ADCSDK.ResourceType.GLOBAL_RULE: { @@ -168,6 +190,7 @@ export class Validator extends ADCSDK.backend.BackendEventSource { plugins: { [event.resourceId]: event.newValue }, } as unknown as typing.GlobalRule); nameIndex.global_rules.push(event.resourceName); + eventIndex.global_rules.push(event); break; } case ADCSDK.ResourceType.PLUGIN_METADATA: { @@ -178,10 +201,11 @@ export class Validator extends ADCSDK.backend.BackendEventSource { ), }); nameIndex.plugin_metadata.push(event.resourceName); + eventIndex.plugin_metadata.push(event); break; } } } - return { body, nameIndex }; + return { body, nameIndex, eventIndex }; } } diff --git a/libs/backend-apisix/test/validator.spec.ts b/libs/backend-apisix/test/validator.spec.ts new file mode 100644 index 00000000..501781db --- /dev/null +++ b/libs/backend-apisix/test/validator.spec.ts @@ -0,0 +1,235 @@ +/** + * Unit tests for the APISIX Validator. + * + * NOTE: These tests are fully mocked — they test that the Validator correctly + * maps APISIX validate errors back to ADC Event objects by asserting on the + * `event` field in `BackendValidationError`. No real HTTP connections are made; + * `axios.post` is stubbed via `vi.spyOn` to return a simulated 400 response. + */ +import * as ADCSDK from '@api7/adc-sdk'; +import axios, { AxiosError } from 'axios'; +import { Subject } from 'rxjs'; + +import { Validator } from '../src/validator'; + +/** + * Helper to create an AxiosError that mimics a 400 validation response. + */ +const createAxios400Error = (data: Record): AxiosError => { + const error = new AxiosError( + 'Request failed with status code 400', + AxiosError.ERR_BAD_REQUEST, + ); + error.response = { + status: 400, + statusText: 'Bad Request', + headers: {}, + data, + config: {} as any, + }; + return error; +}; + +describe('Validator', () => { + const createEvent = ( + resourceType: ADCSDK.ResourceType, + resourceName: string, + parentId?: string, + ): ADCSDK.Event => ({ + type: ADCSDK.EventType.CREATE, + resourceType, + resourceId: ADCSDK.utils.generateId(resourceName), + resourceName, + newValue: { name: resourceName }, + ...(parentId ? { parentId } : {}), + }); + + it('should embed event in validation errors for routes', async () => { + const parentId = ADCSDK.utils.generateId('httpbin.org'); + const events = [ + createEvent(ADCSDK.ResourceType.SERVICE, 'httpbin.org'), + createEvent(ADCSDK.ResourceType.ROUTE, 'get-anything', parentId), + ]; + + const client = axios.create(); + const postSpy = vi.spyOn(client, 'post').mockRejectedValue( + createAxios400Error({ + error_msg: 'Configuration validation failed', + errors: [ + { + resource_type: 'routes', + index: 0, + error: + 'does not match schema due to: Error at "/methods/0": value is not one of the allowed values', + }, + ], + }), + ); + + const validator = new Validator({ + client, + eventSubject: new Subject(), + }); + + const result = await validator.validate(events); + + expect(result.success).toBe(false); + expect(result.errorMessage).toBe('Configuration validation failed'); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].resource_type).toBe('routes'); + expect(result.errors[0].resource_name).toBe('get-anything'); + expect(result.errors[0].event).toBeDefined(); + expect(result.errors[0].event!.resourceType).toBe( + ADCSDK.ResourceType.ROUTE, + ); + expect(result.errors[0].event!.resourceName).toBe('get-anything'); + expect(result.errors[0].event!.parentId).toBe(parentId); + expect(result.errors[0].event!.type).toBe(ADCSDK.EventType.CREATE); + expect(result.errors[0].event!.newValue).toMatchObject({ + name: 'get-anything', + }); + }); + + it('should embed event in validation errors for services', async () => { + const events = [ + createEvent(ADCSDK.ResourceType.SERVICE, 'httpbin.org'), + createEvent(ADCSDK.ResourceType.ROUTE, 'get-anything', 'some-parent'), + ]; + + const client = axios.create(); + const postSpy = vi.spyOn(client, 'post').mockRejectedValue( + createAxios400Error({ + error_msg: 'Configuration validation failed', + errors: [ + { + resource_type: 'services', + index: 0, + error: 'does not match schema due to: plugins validation failed', + }, + ], + }), + ); + + const validator = new Validator({ + client, + eventSubject: new Subject(), + }); + + const result = await validator.validate(events); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].resource_type).toBe('services'); + expect(result.errors[0].resource_name).toBe('httpbin.org'); + expect(result.errors[0].event).toBeDefined(); + expect(result.errors[0].event!.resourceType).toBe( + ADCSDK.ResourceType.SERVICE, + ); + expect(result.errors[0].event!.resourceName).toBe('httpbin.org'); + }); + + it('should return success when no validation errors', async () => { + const events = [createEvent(ADCSDK.ResourceType.SERVICE, 'httpbin.org')]; + + const client = axios.create(); + const postSpy = vi.spyOn(client, 'post').mockResolvedValue({ + status: 200, + data: {}, + }); + + const validator = new Validator({ + client, + eventSubject: new Subject(), + }); + + const result = await validator.validate(events); + + expect(result.success).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should handle multiple errors with correct event mapping', async () => { + const parentId = ADCSDK.utils.generateId('my-service'); + const events = [ + createEvent(ADCSDK.ResourceType.SERVICE, 'my-service'), + createEvent(ADCSDK.ResourceType.ROUTE, 'route-a', parentId), + createEvent(ADCSDK.ResourceType.ROUTE, 'route-b', parentId), + createEvent(ADCSDK.ResourceType.CONSUMER, 'user1'), + ]; + + const client = axios.create(); + const postSpy = vi.spyOn(client, 'post').mockRejectedValue( + createAxios400Error({ + error_msg: 'Configuration validation failed', + errors: [ + { + resource_type: 'routes', + index: 0, + error: 'error on route-a', + }, + { + resource_type: 'routes', + index: 1, + error: 'error on route-b', + }, + { + resource_type: 'consumers', + index: 0, + error: 'error on user1', + }, + ], + }), + ); + + const validator = new Validator({ + client, + eventSubject: new Subject(), + }); + + const result = await validator.validate(events); + + expect(result.errors).toHaveLength(3); + + // First error: routes[0] → route-a + expect(result.errors[0].resource_name).toBe('route-a'); + expect(result.errors[0].event!.resourceName).toBe('route-a'); + expect(result.errors[0].event!.parentId).toBe(parentId); + + // Second error: routes[1] → route-b + expect(result.errors[1].resource_name).toBe('route-b'); + expect(result.errors[1].event!.resourceName).toBe('route-b'); + expect(result.errors[1].event!.parentId).toBe(parentId); + + // Third error: consumers[0] → user1 + expect(result.errors[2].resource_name).toBe('user1'); + expect(result.errors[2].event!.resourceName).toBe('user1'); + expect(result.errors[2].event!.parentId).toBeUndefined(); + }); + + it('should handle events without matching error index gracefully', async () => { + const events = [createEvent(ADCSDK.ResourceType.SERVICE, 'my-service')]; + + const client = axios.create(); + const postSpy = vi.spyOn(client, 'post').mockRejectedValue( + createAxios400Error({ + errors: [ + { + resource_type: 'unknown_type', + index: 0, + error: 'some error', + }, + ], + }), + ); + + const validator = new Validator({ + client, + eventSubject: new Subject(), + }); + + const result = await validator.validate(events); + + expect(result.errors).toHaveLength(1); + // Unknown resource type: event should be undefined, no crash + expect(result.errors[0].event).toBeUndefined(); + }); +}); diff --git a/libs/sdk/src/backend/index.ts b/libs/sdk/src/backend/index.ts index d35bdbea..634f618e 100644 --- a/libs/sdk/src/backend/index.ts +++ b/libs/sdk/src/backend/index.ts @@ -73,6 +73,7 @@ export interface BackendValidationError { resource_name?: string; index: number; error: string; + event?: ADCSDK.Event; } export interface BackendValidateResult { From 0be4d4013bded859893e72db1f08dcb807f13422 Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Wed, 6 May 2026 19:56:36 +0800 Subject: [PATCH 5/6] feat: add error source --- apps/cli/e2e/server/basic.e2e-spec.ts | 88 ------------------ apps/cli/e2e/server/validate.e2e-spec.ts | 109 +++++++++++++++++++++++ apps/cli/src/server/validate.ts | 4 + 3 files changed, 113 insertions(+), 88 deletions(-) create mode 100644 apps/cli/e2e/server/validate.e2e-spec.ts diff --git a/apps/cli/e2e/server/basic.e2e-spec.ts b/apps/cli/e2e/server/basic.e2e-spec.ts index 824c4d53..768ed7f0 100644 --- a/apps/cli/e2e/server/basic.e2e-spec.ts +++ b/apps/cli/e2e/server/basic.e2e-spec.ts @@ -264,94 +264,6 @@ describe('Server - Basic', () => { await server.stop(); }); - it('test validate with empty config', async () => { - const { status, body } = await request(server.TEST_ONLY_getExpress()) - .put('/validate') - .send({ - task: { - opts: { - backend: 'mock', - server: 'http://1.1.1.1:3000', - token: 'mock', - cacheKey: 'default', - }, - config: {}, - }, - }); - - expect(status).toEqual(200); - expect(body.success).toEqual(true); - expect(body.errors).toEqual([]); - }); - - it('test validate with config', async () => { - const config = { - consumers: [ - { - username: 'test-consumer', - plugins: { 'limit-count': { count: 10, time_window: 60 } }, - }, - ], - } as ADCSDK.Configuration; - const { status, body } = await request(server.TEST_ONLY_getExpress()) - .put('/validate') - .send({ - task: { - opts: { - backend: 'mock', - server: 'http://1.1.1.1:3000', - token: 'mock', - cacheKey: 'default', - }, - config, - }, - }); - - expect(status).toEqual(200); - expect(body.success).toEqual(true); - expect(body.errors).toEqual([]); - }); - - it('test validate with invalid input', async () => { - const { status, body } = await request(server.TEST_ONLY_getExpress()) - .put('/validate') - .send({ - task: { - opts: { - server: 'http://1.1.1.1:3000', - token: 'mock', - cacheKey: 'default', - }, - config: {}, - }, - }); - - expect(status).toEqual(400); - expect(body.success).toEqual(false); - }); - - it('test validate with lint failure', async () => { - const { status, body } = await request(server.TEST_ONLY_getExpress()) - .put('/validate') - .send({ - task: { - opts: { - backend: 'mock', - server: 'http://1.1.1.1:3000', - token: 'mock', - lint: true, - cacheKey: 'default', - }, - config: { - invalid_key: {}, - }, - }, - }); - - expect(status).toEqual(400); - expect(body.success).toEqual(false); - }); - it('test status listen', async () => { const server = new ADCServer({ listen: new URL(`http://127.0.0.1:3000`), diff --git a/apps/cli/e2e/server/validate.e2e-spec.ts b/apps/cli/e2e/server/validate.e2e-spec.ts new file mode 100644 index 00000000..a40e841d --- /dev/null +++ b/apps/cli/e2e/server/validate.e2e-spec.ts @@ -0,0 +1,109 @@ +import * as ADCSDK from '@api7/adc-sdk'; +import request from 'supertest'; + +import { ADCServer } from '../../src/server'; +import { jestMockBackend } from '../support/utils'; + +describe('Server - Validate', () => { + let server: ADCServer; + + beforeAll(async () => { + jestMockBackend(); + server = new ADCServer({ + listen: new URL('http://127.0.1:3000'), + listenStatus: 3001, + }); + }); + + it('test validate with empty config', async () => { + const { status, body } = await request(server.TEST_ONLY_getExpress()) + .put('/validate') + .send({ + task: { + opts: { + backend: 'mock', + server: 'http://1.1.1.1:3000', + token: 'mock', + cacheKey: 'default', + }, + config: {}, + }, + }); + + expect(status).toEqual(200); + expect(body.success).toEqual(true); + expect(body.source).toEqual('validate'); + expect(body.errors).toEqual([]); + }); + + it('test validate with config', async () => { + const config = { + consumers: [ + { + username: 'test-consumer', + plugins: { 'limit-count': { count: 10, time_window: 60 } }, + }, + ], + } as ADCSDK.Configuration; + const { status, body } = await request(server.TEST_ONLY_getExpress()) + .put('/validate') + .send({ + task: { + opts: { + backend: 'mock', + server: 'http://1.1.1.1:3000', + token: 'mock', + cacheKey: 'default', + }, + config, + }, + }); + + expect(status).toEqual(200); + expect(body.success).toEqual(true); + expect(body.source).toEqual('validate'); + expect(body.errors).toEqual([]); + }); + + it('test validate with invalid input', async () => { + const { status, body } = await request(server.TEST_ONLY_getExpress()) + .put('/validate') + .send({ + task: { + opts: { + server: 'http://1.1.1.1:3000', + token: 'mock', + cacheKey: 'default', + }, + config: {}, + }, + }); + + expect(status).toEqual(400); + expect(body.success).toEqual(false); + expect(body.source).toEqual('input'); + }); + + it('test validate with lint failure', async () => { + const { status, body } = await request(server.TEST_ONLY_getExpress()) + .put('/validate') + .send({ + task: { + opts: { + backend: 'mock', + server: 'http://1.1.1.1:3000', + token: 'mock', + lint: true, + cacheKey: 'default', + }, + config: { + invalid_key: {}, + }, + }, + }); + + expect(status).toEqual(400); + expect(body.success).toEqual(false); + expect(body.source).toEqual('lint'); + }); +}); diff --git a/apps/cli/src/server/validate.ts b/apps/cli/src/server/validate.ts index 100b0902..a45b9fc2 100644 --- a/apps/cli/src/server/validate.ts +++ b/apps/cli/src/server/validate.ts @@ -40,6 +40,7 @@ export const validateHandler: RequestHandler< if (!parsedInput.success) return res.status(400).json({ success: false, + source: 'input', message: parsedInput.error.message, errors: parsedInput.error.issues, }); @@ -58,6 +59,7 @@ export const validateHandler: RequestHandler< if (!result.success) return res.status(400).json({ success: false, + source: 'lint', message: result.error.message, errors: result.error.issues, }); @@ -111,6 +113,7 @@ export const validateHandler: RequestHandler< if (!backend.validate) return res.status(400).json({ success: false, + source: 'validate', message: 'Validate is not supported by the current backend.', errors: [], }); @@ -128,6 +131,7 @@ export const validateHandler: RequestHandler< res.status(200).json({ success: result.success, + source: 'validate', ...(result.errorMessage ? { message: result.errorMessage } : {}), errors: result.errors, }); From 91bd1a1f7f5df5652f201fd1fe009a35c19b1802 Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Wed, 6 May 2026 20:02:16 +0800 Subject: [PATCH 6/6] fix comments --- apps/cli/src/server/sync.ts | 1 - libs/backend-apisix/test/validator.spec.ts | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/server/sync.ts b/apps/cli/src/server/sync.ts index ae1fb26d..eb02965b 100644 --- a/apps/cli/src/server/sync.ts +++ b/apps/cli/src/server/sync.ts @@ -84,7 +84,6 @@ export const syncHandler: RequestHandler< request: { method: response.config.method, url: response.config.url, - headers: response.config.headers, data: response.config.data, }, response: { diff --git a/libs/backend-apisix/test/validator.spec.ts b/libs/backend-apisix/test/validator.spec.ts index 501781db..49a1a3f9 100644 --- a/libs/backend-apisix/test/validator.spec.ts +++ b/libs/backend-apisix/test/validator.spec.ts @@ -52,7 +52,7 @@ describe('Validator', () => { ]; const client = axios.create(); - const postSpy = vi.spyOn(client, 'post').mockRejectedValue( + vi.spyOn(client, 'post').mockRejectedValue( createAxios400Error({ error_msg: 'Configuration validation failed', errors: [ @@ -97,7 +97,7 @@ describe('Validator', () => { ]; const client = axios.create(); - const postSpy = vi.spyOn(client, 'post').mockRejectedValue( + vi.spyOn(client, 'post').mockRejectedValue( createAxios400Error({ error_msg: 'Configuration validation failed', errors: [ @@ -131,7 +131,7 @@ describe('Validator', () => { const events = [createEvent(ADCSDK.ResourceType.SERVICE, 'httpbin.org')]; const client = axios.create(); - const postSpy = vi.spyOn(client, 'post').mockResolvedValue({ + vi.spyOn(client, 'post').mockResolvedValue({ status: 200, data: {}, }); @@ -157,7 +157,7 @@ describe('Validator', () => { ]; const client = axios.create(); - const postSpy = vi.spyOn(client, 'post').mockRejectedValue( + vi.spyOn(client, 'post').mockRejectedValue( createAxios400Error({ error_msg: 'Configuration validation failed', errors: [ @@ -209,7 +209,7 @@ describe('Validator', () => { const events = [createEvent(ADCSDK.ResourceType.SERVICE, 'my-service')]; const client = axios.create(); - const postSpy = vi.spyOn(client, 'post').mockRejectedValue( + vi.spyOn(client, 'post').mockRejectedValue( createAxios400Error({ errors: [ {