From 1a1b0111c4bbe18da2d9c3cb328a8a8191ec5d89 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah <36107+shazron@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:11:31 +0800 Subject: [PATCH 1/6] fix: include URL in api list --json output Fixes #388 - the --json flag was outputting only `apidoc` (missing the gateway URL). Now outputs the same processed array as the table view, which includes the URL built from `gwApiUrl`. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/runtime/api/list.js | 10 +++++----- test/commands/runtime/api/list.test.js | 8 ++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/commands/runtime/api/list.js b/src/commands/runtime/api/list.js index 646c9f78..6f902bec 100644 --- a/src/commands/runtime/api/list.js +++ b/src/commands/runtime/api/list.js @@ -56,11 +56,6 @@ class ApiList extends RuntimeBaseCommand { const result = await ow.routes.list(options) - if (shouldOutputJson) { - this.logJSON('', result.apis[0].value.apidoc) - return - } - let data = [] result.apis.forEach(api => { // join the two arrays by reduce @@ -70,6 +65,11 @@ class ApiList extends RuntimeBaseCommand { }, data) }) + if (shouldOutputJson) { + this.logJSON('', data) + return + } + table(data, { Action: { minWidth: 10 }, Verb: { minWidth: 10 }, diff --git a/test/commands/runtime/api/list.test.js b/test/commands/runtime/api/list.test.js index e716991b..733b7497 100644 --- a/test/commands/runtime/api/list.test.js +++ b/test/commands/runtime/api/list.test.js @@ -102,9 +102,13 @@ describe('instance methods', () => { .then(() => { const expectedJson = fixtureJson('api/list.json') const output = stdout.output.trim() - const jsonMatch = output.match(/\{[\s\S]*\}$/) + const jsonMatch = output.match(/\[[\s\S]*\]$/) const jsonOutput = jsonMatch ? jsonMatch[0] : output - expect(JSON.parse(jsonOutput)).toMatchObject(expectedJson.apis[0].value.apidoc) + const parsed = JSON.parse(jsonOutput) + // should include URL field built from gwApiUrl + expect(parsed).toBeInstanceOf(Array) + expect(parsed[0]).toHaveProperty('URL') + expect(parsed[0].URL).toContain(expectedJson.apis[0].value.gwApiUrl) }) .finally(() => { stdout.stop() From 9ef0ca1108f75947c2ac9931e03d8042ffbf8994 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah <36107+shazron@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:15:19 +0800 Subject: [PATCH 2/6] fix: populate x-openwhisk.url in api list --json output Fixes #388 - keeps the apidoc JSON structure unchanged but updates the x-openwhisk.url field (previously always "not-used") to the actual gateway URL built from gwApiUrl + path. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/runtime/api/list.js | 22 +++++++++++++++++----- test/commands/runtime/api/list.test.js | 14 +++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/commands/runtime/api/list.js b/src/commands/runtime/api/list.js index 6f902bec..b0cbfc80 100644 --- a/src/commands/runtime/api/list.js +++ b/src/commands/runtime/api/list.js @@ -56,6 +56,23 @@ class ApiList extends RuntimeBaseCommand { const result = await ow.routes.list(options) + if (shouldOutputJson) { + const api = result.apis[0] + const apidoc = api.value.apidoc + const gwApiUrl = api.value.gwApiUrl + Object.keys(apidoc.paths).forEach(path => { + if (!path.startsWith('/')) return + Object.keys(apidoc.paths[path]).forEach(verb => { + const operation = apidoc.paths[path][verb] + if (operation['x-openwhisk']) { + operation['x-openwhisk'].url = `${gwApiUrl}${path}` + } + }) + }) + this.logJSON('', apidoc) + return + } + let data = [] result.apis.forEach(api => { // join the two arrays by reduce @@ -65,11 +82,6 @@ class ApiList extends RuntimeBaseCommand { }, data) }) - if (shouldOutputJson) { - this.logJSON('', data) - return - } - table(data, { Action: { minWidth: 10 }, Verb: { minWidth: 10 }, diff --git a/test/commands/runtime/api/list.test.js b/test/commands/runtime/api/list.test.js index 733b7497..fa848127 100644 --- a/test/commands/runtime/api/list.test.js +++ b/test/commands/runtime/api/list.test.js @@ -102,13 +102,17 @@ describe('instance methods', () => { .then(() => { const expectedJson = fixtureJson('api/list.json') const output = stdout.output.trim() - const jsonMatch = output.match(/\[[\s\S]*\]$/) + const jsonMatch = output.match(/\{[\s\S]*\}$/) const jsonOutput = jsonMatch ? jsonMatch[0] : output const parsed = JSON.parse(jsonOutput) - // should include URL field built from gwApiUrl - expect(parsed).toBeInstanceOf(Array) - expect(parsed[0]).toHaveProperty('URL') - expect(parsed[0].URL).toContain(expectedJson.apis[0].value.gwApiUrl) + const { apis } = expectedJson + const gwApiUrl = apis[0].value.gwApiUrl + // apidoc structure preserved + expect(parsed.basePath).toEqual(apis[0].value.apidoc.basePath) + expect(parsed.info).toMatchObject(apis[0].value.apidoc.info) + expect(parsed.swagger).toEqual(apis[0].value.apidoc.swagger) + // x-openwhisk.url updated with actual gateway URL (was "not-used") + expect(parsed.paths['/mypath'].get['x-openwhisk'].url).toEqual(`${gwApiUrl}/mypath`) }) .finally(() => { stdout.stop() From 5432232504c69563dac03cb84fe781e5204404d4 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah <36107+shazron@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:18:18 +0800 Subject: [PATCH 3/6] test: add coverage for operation without x-openwhisk in api list --json Co-Authored-By: Claude Sonnet 4.6 --- test/commands/runtime/api/list.test.js | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/commands/runtime/api/list.test.js b/test/commands/runtime/api/list.test.js index fa848127..0a075872 100644 --- a/test/commands/runtime/api/list.test.js +++ b/test/commands/runtime/api/list.test.js @@ -119,6 +119,44 @@ describe('instance methods', () => { }) }) + test('--json flag, operation without x-openwhisk leaves url unchanged', () => { + rtLib.mockResolved(rtAction, { + apis: [{ + value: { + gwApiUrl: 'https://example.com/api', + apidoc: { + basePath: '/test', + info: { title: 'test', version: '1.0.0' }, + swagger: '2.0', + paths: { + '/mypath': { + get: { + operationId: 'testOp', + responses: { default: { description: 'Default response' } } + // no x-openwhisk field + } + } + } + } + } + }] + }) + stdout.stop() + stdout.start() + const cmd = new TheCommand(['--json']) + return cmd.run() + .then(() => { + const output = stdout.output.trim() + const jsonMatch = output.match(/\{[\s\S]*\}$/) + const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : output) + // operation without x-openwhisk should be left intact + expect(parsed.paths['/mypath'].get).not.toHaveProperty('x-openwhisk') + }) + .finally(() => { + stdout.stop() + }) + }) + test('error, throws exception', async () => { rtLib.mockRejected(rtAction, new Error('an error')) const error = ['failed to list the api', new Error('an error')] From 1d7626cc6f6d323cd6d6fe4bbf9fce56b9e6f5e7 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah <36107+shazron@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:28:29 +0800 Subject: [PATCH 4/6] fix: guard empty result.apis and avoid mutating SDK response in --json - Return {} when result.apis is empty instead of throwing TypeError - Use structuredClone before modifying apidoc to prevent in-place mutation - Add tests for empty result and non-mutation of the response object Co-Authored-By: Claude Sonnet 4.6 --- src/commands/runtime/api/list.js | 6 ++++- test/commands/runtime/api/list.test.js | 33 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/commands/runtime/api/list.js b/src/commands/runtime/api/list.js index b0cbfc80..8044dbc0 100644 --- a/src/commands/runtime/api/list.js +++ b/src/commands/runtime/api/list.js @@ -58,8 +58,12 @@ class ApiList extends RuntimeBaseCommand { if (shouldOutputJson) { const api = result.apis[0] - const apidoc = api.value.apidoc + if (!api) { + this.logJSON('', {}) + return + } const gwApiUrl = api.value.gwApiUrl + const apidoc = structuredClone(api.value.apidoc) Object.keys(apidoc.paths).forEach(path => { if (!path.startsWith('/')) return Object.keys(apidoc.paths[path]).forEach(verb => { diff --git a/test/commands/runtime/api/list.test.js b/test/commands/runtime/api/list.test.js index 0a075872..1ebef964 100644 --- a/test/commands/runtime/api/list.test.js +++ b/test/commands/runtime/api/list.test.js @@ -157,6 +157,39 @@ describe('instance methods', () => { }) }) + test('--json flag, empty result.apis returns {}', () => { + rtLib.mockResolved(rtAction, { apis: [] }) + stdout.stop() + stdout.start() + const cmd = new TheCommand(['--json']) + return cmd.run() + .then(() => { + const output = stdout.output.trim() + const jsonMatch = output.match(/\{[\s\S]*\}$/) + const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : output) + expect(parsed).toEqual({}) + }) + .finally(() => { + stdout.stop() + }) + }) + + test('--json flag, does not mutate the SDK response object', () => { + const fixture = fixtureJson('api/list.json') + rtLib.mockResolved(rtAction, fixture) + const originalUrl = fixture.apis[0].value.apidoc.paths['/mypath'].get['x-openwhisk'].url + stdout.stop() + stdout.start() + const cmd = new TheCommand(['--json']) + return cmd.run() + .then(() => { + expect(fixture.apis[0].value.apidoc.paths['/mypath'].get['x-openwhisk'].url).toEqual(originalUrl) + }) + .finally(() => { + stdout.stop() + }) + }) + test('error, throws exception', async () => { rtLib.mockRejected(rtAction, new Error('an error')) const error = ['failed to list the api', new Error('an error')] From b93115da4fbc1de01510062d22e77faab26bcbdf Mon Sep 17 00:00:00 2001 From: Shazron Abdullah <36107+shazron@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:30:59 +0800 Subject: [PATCH 5/6] fix: add defensive guards for api.value, apidoc, and paths in --json Guard api.value?.apidoc and apidoc.paths with nullish fallbacks to prevent TypeError when the SDK response has missing nested fields. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/runtime/api/list.js | 6 ++--- test/commands/runtime/api/list.test.js | 34 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/commands/runtime/api/list.js b/src/commands/runtime/api/list.js index 8044dbc0..0a58451a 100644 --- a/src/commands/runtime/api/list.js +++ b/src/commands/runtime/api/list.js @@ -62,9 +62,9 @@ class ApiList extends RuntimeBaseCommand { this.logJSON('', {}) return } - const gwApiUrl = api.value.gwApiUrl - const apidoc = structuredClone(api.value.apidoc) - Object.keys(apidoc.paths).forEach(path => { + const gwApiUrl = api.value?.gwApiUrl + const apidoc = structuredClone(api.value?.apidoc ?? {}) + Object.keys(apidoc.paths ?? {}).forEach(path => { if (!path.startsWith('/')) return Object.keys(apidoc.paths[path]).forEach(verb => { const operation = apidoc.paths[path][verb] diff --git a/test/commands/runtime/api/list.test.js b/test/commands/runtime/api/list.test.js index 1ebef964..b9b5d52d 100644 --- a/test/commands/runtime/api/list.test.js +++ b/test/commands/runtime/api/list.test.js @@ -157,6 +157,40 @@ describe('instance methods', () => { }) }) + test('--json flag, api.value.apidoc is missing returns {}', () => { + rtLib.mockResolved(rtAction, { apis: [{ value: {} }] }) + stdout.stop() + stdout.start() + const cmd = new TheCommand(['--json']) + return cmd.run() + .then(() => { + const output = stdout.output.trim() + const jsonMatch = output.match(/\{[\s\S]*\}$/) + const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : output) + expect(parsed).toEqual({}) + }) + .finally(() => { + stdout.stop() + }) + }) + + test('--json flag, api.value.apidoc.paths is missing returns apidoc without paths', () => { + rtLib.mockResolved(rtAction, { apis: [{ value: { gwApiUrl: 'https://example.com', apidoc: { basePath: '/test' } } }] }) + stdout.stop() + stdout.start() + const cmd = new TheCommand(['--json']) + return cmd.run() + .then(() => { + const output = stdout.output.trim() + const jsonMatch = output.match(/\{[\s\S]*\}$/) + const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : output) + expect(parsed.basePath).toEqual('/test') + }) + .finally(() => { + stdout.stop() + }) + }) + test('--json flag, empty result.apis returns {}', () => { rtLib.mockResolved(rtAction, { apis: [] }) stdout.stop() From 6019b58d0670e00f40c4c60c3c4efa3a8e3efb5c Mon Sep 17 00:00:00 2001 From: Shazron Abdullah <36107+shazron@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:33:57 +0800 Subject: [PATCH 6/6] fix: guard result.apis null access and non-object operation values - Use optional chaining on result.apis?.[0] to handle null/undefined apis field - Guard operation access with typeof check before reading x-openwhisk, since OpenAPI verbs can hold non-object values (e.g. boolean true) Co-Authored-By: Claude Sonnet 4.6 --- src/commands/runtime/api/list.js | 4 +-- test/commands/runtime/api/list.test.js | 48 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/commands/runtime/api/list.js b/src/commands/runtime/api/list.js index 0a58451a..ba23ea1c 100644 --- a/src/commands/runtime/api/list.js +++ b/src/commands/runtime/api/list.js @@ -57,7 +57,7 @@ class ApiList extends RuntimeBaseCommand { const result = await ow.routes.list(options) if (shouldOutputJson) { - const api = result.apis[0] + const api = result.apis?.[0] if (!api) { this.logJSON('', {}) return @@ -68,7 +68,7 @@ class ApiList extends RuntimeBaseCommand { if (!path.startsWith('/')) return Object.keys(apidoc.paths[path]).forEach(verb => { const operation = apidoc.paths[path][verb] - if (operation['x-openwhisk']) { + if (operation && typeof operation === 'object' && operation['x-openwhisk']) { operation['x-openwhisk'].url = `${gwApiUrl}${path}` } }) diff --git a/test/commands/runtime/api/list.test.js b/test/commands/runtime/api/list.test.js index b9b5d52d..15a7d461 100644 --- a/test/commands/runtime/api/list.test.js +++ b/test/commands/runtime/api/list.test.js @@ -157,6 +157,54 @@ describe('instance methods', () => { }) }) + test('--json flag, result.apis is null returns {}', () => { + rtLib.mockResolved(rtAction, { apis: null }) + stdout.stop() + stdout.start() + const cmd = new TheCommand(['--json']) + return cmd.run() + .then(() => { + const output = stdout.output.trim() + const jsonMatch = output.match(/\{[\s\S]*\}$/) + const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : output) + expect(parsed).toEqual({}) + }) + .finally(() => { + stdout.stop() + }) + }) + + test('--json flag, non-object operation value is skipped safely', () => { + rtLib.mockResolved(rtAction, { + apis: [{ + value: { + gwApiUrl: 'https://example.com/api', + apidoc: { + basePath: '/test', + paths: { + '/mypath': { + get: true + } + } + } + } + }] + }) + stdout.stop() + stdout.start() + const cmd = new TheCommand(['--json']) + return cmd.run() + .then(() => { + const output = stdout.output.trim() + const jsonMatch = output.match(/\{[\s\S]*\}$/) + const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : output) + expect(parsed.paths['/mypath'].get).toEqual(true) + }) + .finally(() => { + stdout.stop() + }) + }) + test('--json flag, api.value.apidoc is missing returns {}', () => { rtLib.mockResolved(rtAction, { apis: [{ value: {} }] }) stdout.stop()