diff --git a/lib/src/wso2/__tests__/wso2-utils.ts b/lib/src/wso2/__tests__/wso2-utils.ts new file mode 100644 index 0000000..96b50fb --- /dev/null +++ b/lib/src/wso2/__tests__/wso2-utils.ts @@ -0,0 +1,30 @@ +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { mockClient } from 'aws-sdk-client-mock'; +import nock from 'nock'; + +export const nockBasicWso2SDK = (baseWso2Url: string): void => { + const secretMock = mockClient(SecretsManagerClient); + secretMock.on(GetSecretValueCommand).resolves({ + SecretBinary: Buffer.from(JSON.stringify({ user: 'user1', pwd: 'pwd1' })), + }); + + // register client mock + nock(baseWso2Url).post('/client-registration/v0.17/register').reply(200, { + clientId: 'clientId1', + clientSecret: 'clientSecret1', + }); + + // get token mock + nock(baseWso2Url).post('/oauth2/token').reply(200, { + // eslint-disable-next-line camelcase + access_token: '1111-1111-1111', + }); + + // mock server check + nock(baseWso2Url) + .get('/services/Version') + .reply( + 200, + 'WSO2 API Manager-3.2.0', + ); +}; diff --git a/lib/src/wso2/wso2-api-subscription/handler/index.test.ts b/lib/src/wso2/wso2-api-subscription/handler/index.test.ts new file mode 100644 index 0000000..c36debb --- /dev/null +++ b/lib/src/wso2/wso2-api-subscription/handler/index.test.ts @@ -0,0 +1,645 @@ +/* eslint-disable no-console */ +import nock from 'nock'; + +import { Wso2SubscriptionDefinition } from '../v1/types'; +import { Wso2ApiSubscriptionProps } from '../types'; +import { nockBasicWso2SDK } from '../../__tests__/wso2-utils'; + +import { handler, Wso2ApiSubscriptionCustomResourceEvent } from './index'; + +const baseWso2Url = 'https://mywso2.com'; + +const testRetryOptions = { + checkRetries: { + startingDelay: 100, + delayFirstAttempt: true, + maxDelay: 100, + numOfAttempts: 3, + timeMultiple: 1.1, + }, + mutationRetries: { + startingDelay: 100, + delayFirstAttempt: true, + maxDelay: 100, + numOfAttempts: 3, + timeMultiple: 1.1, + }, +}; + +const originalConsoleLog = console.log; + +describe('wso2 subscription custom resource lambda', () => { + describe('request type create', () => { + beforeEach(() => { + nock.cleanAll(); + // silence verbose console logs. comment this for debugging + console.log = (): void => {}; + }); + + afterEach(() => { + console.log = originalConsoleLog; + }); + + it('should create the subscription', async () => { + nockBasicWso2SDK(baseWso2Url); + const testDefs: Wso2SubscriptionDefinition = testSubscriptionDefs(); + + // get api mock + nock(baseWso2Url) + .get(/.*\/publisher\/v1\/apis\/111-222/) + .times(1) + .reply(200, { id: '111-222' }); + + // get application mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications\/333-444/) + .times(1) + .reply(200, { applicationId: '333-444' }); + + // subscriptions list mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions.*/) + .query(true) + .times(1) + .reply(200, { list: [] }); + + // subscription get mock + const getSubscriptionNock = nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions\/123-456/) + .times(1) // check if was created + .reply(200, { ...testDefs, throttlingPolicy: 'Unlimited' }); + + // subscription create mock + const createNock = nock(baseWso2Url) + .post(/.*\/store\/v1\/subscriptions/) + .times(1) + .reply(200, { subscriptionId: '123-456' }); + + const eres = await handler(testCFNEventCreate(testEvent)); + expect(eres.Status).toBe('SUCCESS'); + + // make sure that the subscription create was called + createNock.done(); + getSubscriptionNock.done(); + }); + + it('should create the subscription by searching for api and application', async () => { + nockBasicWso2SDK(baseWso2Url); + const testDefs: Wso2SubscriptionDefinition = testSubscriptionDefs(); + + const apiSearchParameters = { + name: 'my-api', + version: 'v1', + context: '/my-api', + }; + + // search api mock + nock(baseWso2Url) + .get(/.*\/publisher\/v1\/apis/) + .query({ query: 'name:my-api version:v1 context:/my-api' }) + .times(1) + .reply(200, { list: [{ id: '111-222', ...apiSearchParameters }] }); + + // get application mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications/) + .query({ query: 'my-app' }) + .times(1) + .reply(200, { list: [{ applicationId: '333-444' }] }); + + // subscriptions list mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions.*/) + .query(true) + .times(1) // check create or update + .reply(200, { list: [] }); + + // subscription get mock + const getSubscriptionNock = nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions\/123-456/) + .times(1) + .reply(200, { ...testDefs, throttlingPolicy: 'Unlimited' }); + + // subscription create mock + const createNock = nock(baseWso2Url) + .post(/.*\/store\/v1\/subscriptions/) + .times(1) + .reply(200, { subscriptionId: '123-456' }); + + const eres = await handler( + testCFNEventCreate({ + ...testEvent, + // eslint-disable-next-line no-undefined + apiId: undefined, + // eslint-disable-next-line no-undefined + applicationId: undefined, + apiSearchParameters, + applicationSearchParameters: { + name: 'my-app', + }, + }), + ); + expect(eres.Status).toBe('SUCCESS'); + + // make sure that the subscription create was called + createNock.done(); + getSubscriptionNock.done(); + }); + + it('should skip update subscription when no changes are detected', async () => { + nockBasicWso2SDK(baseWso2Url); + const testDefs: Wso2SubscriptionDefinition = testSubscriptionDefs(); + + // get api mock + nock(baseWso2Url) + .get(/.*\/publisher\/v1\/apis\/111-222/) + .times(1) + .reply(200, { id: '111-222' }); + + // get application mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications\/333-444/) + .times(1) + .reply(200, { applicationId: '333-444' }); + + // subscriptions list mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions.*/) + .query(true) + .times(1) + .reply(200, { list: [{ ...testDefs, subscriptionId: '123-456' }] }); + + // subscription create mock + const createNock = nock(baseWso2Url) + .post(/.*\/store\/v1\/subscriptions/) + .times(1) + .reply(200, { subscriptionId: '123-456' }); + + // subscription update mock + const updateNock = nock(baseWso2Url) + .put(/.*\/store\/v1\/subscriptions\/123-456/) + .times(1) + .reply(200); + + const eres = await handler(testCFNEventCreate(testEvent)); + expect(eres.Status).toBe('SUCCESS'); + + expect(updateNock.isDone()).toBeFalsy(); + expect(createNock.isDone()).toBeFalsy(); + }); + + it('should update subscription when throttling policy changes', async () => { + nockBasicWso2SDK(baseWso2Url); + const testDefs: Wso2SubscriptionDefinition = testSubscriptionDefs('Gold'); + + // get api mock + nock(baseWso2Url) + .get(/.*\/publisher\/v1\/apis\/111-222/) + .times(1) + .reply(200, { id: '111-222' }); + + // get application mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications\/333-444/) + .times(1) + .reply(200, { applicationId: '333-444' }); + + // subscriptions list mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions.*/) + .query(true) + .times(1) + .reply(200, { list: [{ ...testDefs, subscriptionId: '123-456' }] }); + + // subscription create mock + const createNock = nock(baseWso2Url) + .post(/.*\/store\/v1\/subscriptions/) + .times(1) + .reply(200, { subscriptionId: '123-456' }); + + // subscription update mock + const updateNock = nock(baseWso2Url) + .put(/.*\/store\/v1\/subscriptions\/123-456/) + .times(1) + .reply(200); + + const eres = await handler(testCFNEventCreate(testEvent)); + expect(eres.Status).toBe('SUCCESS'); + + updateNock.done(); + expect(createNock.isDone()).toBeFalsy(); + }); + + it('should fail if subscription already exists and failIfExists is true', async () => { + nockBasicWso2SDK(baseWso2Url); + const testDefs: Wso2SubscriptionDefinition = testSubscriptionDefs(); + + // get api mock + nock(baseWso2Url) + .get(/.*\/publisher\/v1\/apis\/111-222/) + .times(1) + .reply(200, { id: '111-222' }); + + // get application mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications\/333-444/) + .times(1) + .reply(200, { applicationId: '333-444' }); + + // subscriptions list mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions.*/) + .query(true) + .times(1) + .reply(200, { list: [{ ...testDefs, subscriptionId: '123-456' }] }); + + // subscription create mock + const createNock = nock(baseWso2Url) + .post(/.*\/store\/v1\/subscriptions/) + .times(1) + .reply(200, { subscriptionId: '123-456' }); + + // subscription update mock + const updateNock = nock(baseWso2Url) + .put(/.*\/store\/v1\/subscriptions\/123-456/) + .times(1) + .reply(200); + + await expect( + handler(testCFNEventCreate({ ...testEvent, failIfExists: true })), + ).rejects.toThrow( + `Error: WSO2 Subscription '123-456' already exists but cannot be managed by this resource. Change 'failIfExists' to change this behavior`, + ); + + expect(updateNock.isDone()).toBeFalsy(); + expect(createNock.isDone()).toBeFalsy(); + }); + }); + + describe('request type update', () => { + beforeEach(() => { + nock.cleanAll(); + // silence verbose console logs. comment this for debugging + console.log = (): void => {}; + }); + afterEach(() => { + console.log = originalConsoleLog; + }); + + it('should update the subscription', async () => { + nockBasicWso2SDK(baseWso2Url); + const testDefs: Wso2SubscriptionDefinition = testSubscriptionDefs('Gold'); + + // get api mock + nock(baseWso2Url) + .get(/.*\/publisher\/v1\/apis\/111-222/) + .times(1) + .reply(200, { id: '111-222' }); + + // get application mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications\/333-444/) + .times(1) + .reply(200, { applicationId: '333-444' }); + + // subscriptions list mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions.*/) + .query(true) + .times(1) + .reply(200, { list: [{ ...testDefs, subscriptionId: '123-456' }] }); + + // subscription update mock + const updateNock = nock(baseWso2Url) + .put(/.*\/store\/v1\/subscriptions\/123-456/) + .times(1) + .reply(200); + + const eres = await handler(testCFNEventUpdate(testEvent, '123-456')); + expect(eres.Status).toBe('SUCCESS'); + + // make sure that the subscription update was called + updateNock.done(); + }); + + it('should update the subscription by searching for api and application', async () => { + nockBasicWso2SDK(baseWso2Url); + const testDefs: Wso2SubscriptionDefinition = testSubscriptionDefs('Gold'); + + const apiSearchParameters = { + name: 'my-api', + version: 'v1', + context: '/my-api', + }; + + // search api mock + nock(baseWso2Url) + .get(/.*\/publisher\/v1\/apis/) + .query({ query: 'name:my-api version:v1 context:/my-api' }) + .times(1) + .reply(200, { list: [{ id: '111-222', ...apiSearchParameters }] }); + + // get application mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications/) + .query({ query: 'my-app' }) + .times(1) + .reply(200, { list: [{ applicationId: '333-444' }] }); + + // subscriptions list mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions.*/) + .query(true) + .times(1) // check create or update + .reply(200, { list: [{ ...testDefs, subscriptionId: '123-456' }] }); + + // subscription update mock + const updateNock = nock(baseWso2Url) + .put(/.*\/store\/v1\/subscriptions\/123-456/) + .times(1) + .reply(200); + + const eres = await handler( + testCFNEventUpdate( + { + ...testEvent, + // eslint-disable-next-line no-undefined + apiId: undefined, + // eslint-disable-next-line no-undefined + applicationId: undefined, + apiSearchParameters, + applicationSearchParameters: { + name: 'my-app', + }, + }, + '123-456', + ), + ); + expect(eres.Status).toBe('SUCCESS'); + + // make sure that the subscription update was called + updateNock.done(); + }); + + it('should skip update subscription when no changes are detected', async () => { + nockBasicWso2SDK(baseWso2Url); + const testDefs: Wso2SubscriptionDefinition = testSubscriptionDefs(); + + // get api mock + nock(baseWso2Url) + .get(/.*\/publisher\/v1\/apis\/111-222/) + .times(1) + .reply(200, { id: '111-222' }); + + // get application mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications\/333-444/) + .times(1) + .reply(200, { applicationId: '333-444' }); + + // subscriptions list mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions.*/) + .query(true) + .times(1) // check create or update + .reply(200, { list: [{ ...testDefs, subscriptionId: '123-456' }] }); + + // subscription update mock + const updateNock = nock(baseWso2Url) + .put(/.*\/store\/v1\/subscriptions\/123-456/) + .reply(200); + + const eres = await handler(testCFNEventUpdate(testEvent, '123-456')); + expect(eres.Status).toBe('SUCCESS'); + expect(updateNock.isDone()).toBeFalsy(); + }); + }); + + describe('request type delete', () => { + beforeEach(() => { + nock.cleanAll(); + // silence verbose console logs. comment this for debugging + console.log = (): void => {}; + }); + afterEach(() => { + console.log = originalConsoleLog; + }); + + it('should delete the subscription', async () => { + nockBasicWso2SDK(baseWso2Url); + + // subscription delete mock + nock(baseWso2Url) + .delete(/.*\/store\/v1\/subscriptions\/123-456/) + .times(1) + .reply(200); + + const eres = await handler(testCFNEventDelete(testEvent, '123-456')); + expect(eres.Status).toBe('SUCCESS'); + }); + + it('should return success when the subscription is already deleted', async () => { + nockBasicWso2SDK(baseWso2Url); + + // subscription delete mock + nock(baseWso2Url) + .delete(/.*\/store\/v1\/subscriptions\/123-456/) + .times(1) + .reply(404, { message: 'subscription not found' }); + + const eres = await handler(testCFNEventDelete(testEvent, '123-456')); + expect(eres.Status).toBe('SUCCESS'); + }); + }); + + describe('retries', () => { + beforeEach(() => { + nock.cleanAll(); + // silence verbose console logs. comment this for debugging + console.log = (): void => {}; + }); + afterEach(() => { + console.log = originalConsoleLog; + }); + + it('should retry on create subscription', async () => { + nockBasicWso2SDK(baseWso2Url); + const testDefs: Wso2SubscriptionDefinition = testSubscriptionDefs(); + + // get api mock + nock(baseWso2Url) + .get(/.*\/publisher\/v1\/apis\/111-222/) + .times(1) + .reply(200, { id: '111-222' }); + + // get application mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications\/333-444/) + .times(1) + .reply(200, { applicationId: '333-444' }); + + // subscriptions list mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions.*/) + .query(true) + .times(1) + .reply(200, { list: [] }); + + // subscription get mock (checks if the api was created) + const getSubscriptionFailNock = nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions\/123-456/) + .times(2) + .reply(500); + const getSubscriptionSuccessNock = nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions\/123-456/) + .times(1) + .reply(200, testDefs); + + // subscription create mock + const createFailNock = nock(baseWso2Url) + .post(/.*\/store\/v1\/subscriptions/) + .times(2) + .reply(500); + + const createSuccessNock = nock(baseWso2Url) + .post(/.*\/store\/v1\/subscriptions/) + .times(1) + .reply(200, { subscriptionId: '123-456' }); + + const eres = await handler(testCFNEventCreate(testEvent)); + expect(eres.Status).toBe('SUCCESS'); + + createFailNock.done(); + createSuccessNock.done(); + getSubscriptionFailNock.done(); + getSubscriptionSuccessNock.done(); + }); + + it('should retry on update subscription', async () => { + nockBasicWso2SDK(baseWso2Url); + const testDefs: Wso2SubscriptionDefinition = testSubscriptionDefs('Gold'); + + // get api mock + nock(baseWso2Url) + .get(/.*\/publisher\/v1\/apis\/111-222/) + .times(1) + .reply(200, { id: '111-222' }); + + // get application mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications\/333-444/) + .times(1) + .reply(200, { applicationId: '333-444' }); + + // subscriptions list mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/subscriptions.*/) + .query(true) + .times(1) + .reply(200, { list: [{ ...testDefs, subscriptionId: '123-456' }] }); + + // subscription update mock + const updateFailNock = nock(baseWso2Url) + .put(/.*\/store\/v1\/subscriptions\/123-456/) + .times(2) + .reply(500); + + const updateSuccessNock = nock(baseWso2Url) + .put(/.*\/store\/v1\/subscriptions\/123-456/) + .times(1) + .reply(200, { subscriptionId: '123-456' }); + + const eres = await handler(testCFNEventUpdate(testEvent, '123-456')); + expect(eres.Status).toBe('SUCCESS'); + + updateFailNock.done(); + updateSuccessNock.done(); + }); + + it('should retry on delete subscription', async () => { + nockBasicWso2SDK(baseWso2Url); + + // subscription delete mock + const deleteFailMock = nock(baseWso2Url) + .delete(/.*\/store\/v1\/subscriptions\/123-456/) + .times(2) + .reply(500); + + const deleteSuccessMock = nock(baseWso2Url) + .delete(/.*\/store\/v1\/subscriptions\/123-456/) + .times(1) + .reply(200); + + const eres = await handler(testCFNEventDelete(testEvent, '123-456')); + expect(eres.Status).toBe('SUCCESS'); + + deleteFailMock.done(); + deleteSuccessMock.done(); + }); + }); + + const testSubscriptionDefs = ( + throttlingPolicy?: Wso2SubscriptionDefinition['throttlingPolicy'], + ): Wso2SubscriptionDefinition => { + return { + apiId: '111-222', + applicationId: '333-444', + throttlingPolicy: throttlingPolicy ?? 'Unlimited', + }; + }; + + const commonEvt = { + StackId: 'test-stack', + RequestId: '123-123123', + LogicalResourceId: 'abc abc', + ServiceToken: 'arn:somelambdatest', + ResponseURL: 's3bucketxxx', + ResourceType: 'wso2subscription', + }; + + const testCFNEventCreate = ( + baseProperties: Wso2ApiSubscriptionProps, + ): Wso2ApiSubscriptionCustomResourceEvent => { + return { + ...commonEvt, + RequestType: 'Create', + ResourceProperties: { ...baseProperties, ServiceToken: 'arn:somelambdatest' }, + }; + }; + + const testCFNEventUpdate = ( + baseProperties: Wso2ApiSubscriptionProps, + PhysicalResourceId: string, + OldResourceProperties: Record = {}, + ): Wso2ApiSubscriptionCustomResourceEvent => { + return { + ...commonEvt, + RequestType: 'Update', + ResourceProperties: { ...baseProperties, ServiceToken: 'arn:somelambdatest' }, + PhysicalResourceId, + OldResourceProperties, + }; + }; + + const testCFNEventDelete = ( + baseProperties: Wso2ApiSubscriptionProps, + PhysicalResourceId: string, + ): Wso2ApiSubscriptionCustomResourceEvent => { + return { + ...commonEvt, + RequestType: 'Delete', + ResourceProperties: { ...baseProperties, ServiceToken: 'arn:somelambdatest' }, + PhysicalResourceId, + }; + }; + + const testEvent: Wso2ApiSubscriptionProps = { + wso2Config: { + baseApiUrl: baseWso2Url, + credentialsSecretId: 'arn:aws:secretsmanager:us-east-1:123123123:secret:MySecret', + apiVersion: 'v1', + }, + ...testSubscriptionDefs(), + retryOptions: testRetryOptions, + }; +}); diff --git a/lib/src/wso2/wso2-api-subscription/handler/index.ts b/lib/src/wso2/wso2-api-subscription/handler/index.ts new file mode 100644 index 0000000..10db043 --- /dev/null +++ b/lib/src/wso2/wso2-api-subscription/handler/index.ts @@ -0,0 +1,170 @@ +/* eslint-disable no-console */ +import { AxiosInstance } from 'axios'; +import { CdkCustomResourceEvent, CdkCustomResourceResponse } from 'aws-lambda'; + +import { prepareAxiosForWso2Calls } from '../../wso2-utils'; +import { applyRetryDefaults, truncateStr } from '../../utils'; +import { Wso2ApiSubscriptionProps } from '../types'; + +import { + createWso2ApiSubscription, + findWso2ApiSubscription, + getWso2Api, + getWso2Application, + removeWso2ApiSubscription, + updateWso2ApiSubscription, +} from './wso2-v1'; + +export type Wso2ApiSubscriptionCustomResourceEvent = CdkCustomResourceEvent & { + ResourceProperties: Wso2ApiSubscriptionProps; +}; + +export type Wso2ApiCustomResourceResponse = CdkCustomResourceResponse & { + Data?: { + Wso2ApiId?: string; + SubscriptionId?: string; + ApplicationId?: string; + Error?: unknown; + }; + Status?: 'SUCCESS' | 'FAILED'; + Reason?: string; +}; + +export const handler = async ( + event: Wso2ApiSubscriptionCustomResourceEvent, +): Promise => { + const response: Wso2ApiCustomResourceResponse = { + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + }; + + try { + console.log('>>> Prepare WSO2 API client...'); + const wso2Axios = await prepareAxiosForWso2Calls(event.ResourceProperties.wso2Config); + + if (event.RequestType === 'Create' || event.RequestType === 'Update') { + console.log('>>> Creating or Updating WSO2 API Subscription...'); + + const { wso2ApiId, subscriptionId, applicationId } = await createOrUpdateWso2ApiSubscription( + event, + wso2Axios, + ); + + return { + ...response, + PhysicalResourceId: subscriptionId, + Data: { + Wso2ApiId: wso2ApiId, + SubscriptionId: subscriptionId, + ApplicationId: applicationId, + }, + Status: 'SUCCESS', + }; + } + + if (event.RequestType === 'Delete') { + console.log('>>> Deleting WSO2 API Subscription...'); + + await removeWso2ApiSubscription({ + wso2Axios, + subscriptionId: event.PhysicalResourceId, + retryOptions: applyRetryDefaults(event.ResourceProperties.retryOptions), + }); + + return { + ...response, + PhysicalResourceId: event.PhysicalResourceId, + Status: 'SUCCESS', + }; + } + throw new Error('Unrecognized RequestType'); + } catch (error) { + console.log(`An error has occurred. err=${error}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const err = error as any; + if (err.stack) { + console.log(err.stack); + } + throw new Error(truncateStr(`${error}`, 1000)); + } +}; + +const createOrUpdateWso2ApiSubscription = async ( + event: Wso2ApiSubscriptionCustomResourceEvent, + wso2Axios: AxiosInstance, +): Promise<{ wso2ApiId: string; subscriptionId: string; applicationId: string }> => { + console.log(`Verifying if WSO2 API ${event.ResourceProperties.apiId} exists in WSO2...`); + const wso2Api = await getWso2Api({ + wso2Axios, + apiId: event.ResourceProperties.apiId, + wso2Tenant: event.ResourceProperties.wso2Config.tenant, + apiSearchParameters: event.ResourceProperties.apiSearchParameters, + }); + + console.log( + `Verifying if WSO2 Application ${event.ResourceProperties.applicationId} exists in WSO2...`, + ); + const wso2Application = await getWso2Application({ + wso2Axios, + applicationId: event.ResourceProperties.applicationId, + applicationSearchParameters: event.ResourceProperties.applicationSearchParameters, + }); + + const wso2Subscription = await findWso2ApiSubscription({ + wso2Axios, + apiId: wso2Api.id!, + applicationId: wso2Application.applicationId!, + }); + + if (event.RequestType === 'Create' && wso2Subscription && event.ResourceProperties.failIfExists) { + throw new Error( + `WSO2 Subscription '${wso2Subscription.subscriptionId}' already exists but cannot be managed by this resource. Change 'failIfExists' to change this behavior`, + ); + } + + if ( + wso2Subscription && + wso2Subscription.throttlingPolicy === event.ResourceProperties.throttlingPolicy + ) { + console.log('Current subscription already exists with the same configuration. Skipping update'); + return { + wso2ApiId: wso2Api.id!, + subscriptionId: wso2Subscription.subscriptionId!, + applicationId: wso2Application.applicationId!, + }; + } + + if (wso2Subscription) { + console.log('Subscription already exists. Updating...'); + const result = await updateWso2ApiSubscription({ + wso2Axios, + subscriptionId: wso2Subscription.subscriptionId!, + apiId: wso2Api.id!, + applicationId: wso2Application.applicationId!, + throttlingPolicy: event.ResourceProperties.throttlingPolicy, + retryOptions: applyRetryDefaults(event.ResourceProperties.retryOptions), + }); + + return { + wso2ApiId: wso2Api.id!, + subscriptionId: result.subscriptionId!, + applicationId: wso2Application.applicationId!, + }; + } + + console.log('Creating a new subscription...'); + const result = await createWso2ApiSubscription({ + wso2Axios, + apiId: wso2Api.id!, + applicationId: wso2Application.applicationId!, + throttlingPolicy: event.ResourceProperties.throttlingPolicy, + retryOptions: applyRetryDefaults(event.ResourceProperties.retryOptions), + }); + + return { + wso2ApiId: wso2Api.id!, + subscriptionId: result.subscriptionId!, + applicationId: wso2Application.applicationId!, + }; +}; diff --git a/lib/src/wso2/wso2-api-subscription/handler/wso2-v1.ts b/lib/src/wso2/wso2-api-subscription/handler/wso2-v1.ts new file mode 100644 index 0000000..be4c8f1 --- /dev/null +++ b/lib/src/wso2/wso2-api-subscription/handler/wso2-v1.ts @@ -0,0 +1,236 @@ +/* eslint-disable no-console */ +import { AxiosInstance } from 'axios'; +import { backOff } from 'exponential-backoff'; + +import { Wso2ApiSubscriptionProps } from '../types'; +import { findWso2Api, getWso2ApiById } from '../../wso2-api/handler/wso2-v1'; +import { ApiFromListV1 } from '../../wso2-api/v1/types'; +import { + findWso2Application, + getWso2ApplicationById, +} from '../../wso2-application/handler/wso2-v1'; +import { Wso2ApplicationInfo } from '../../wso2-application/v1/types'; +import { + Wso2SubscriptionDefinition, + Wso2SubscriptionInfo, + Wso2SubscriptionList, +} from '../v1/types'; + +export type GetWso2ApiArgs = Pick & { + wso2Axios: AxiosInstance; + wso2Tenant?: string; +}; + +export const getWso2Api = async ({ + wso2Axios, + apiId, + wso2Tenant, + apiSearchParameters, +}: GetWso2ApiArgs): Promise => { + if (apiId) { + console.log('Getting WSO2 API by id...'); + const apiDetails = await getWso2ApiById({ wso2Axios, wso2ApiId: apiId }); + return apiDetails; + } + + if (!apiSearchParameters) { + throw new Error('apiSearchParameters is required for searching API'); + } + + console.log('Getting WSO2 API by search parameters...'); + const apiDetails = await findWso2Api({ + wso2Axios, + apiContext: apiSearchParameters.context, + apiName: apiSearchParameters.name, + apiVersion: apiSearchParameters.version, + wso2Tenant, + }); + + if (!apiDetails) { + throw new Error( + `Cannot find the WSO2 API from the provided search parameters (name=${apiSearchParameters.name}; version=${apiSearchParameters.version}; context=${apiSearchParameters.context})`, + ); + } + + return apiDetails; +}; + +export type GetWso2ApplicationArgs = Pick< + Wso2ApiSubscriptionProps, + 'applicationId' | 'applicationSearchParameters' +> & { + wso2Axios: AxiosInstance; +}; + +export const getWso2Application = async ({ + wso2Axios, + applicationId, + applicationSearchParameters, +}: GetWso2ApplicationArgs): Promise => { + if (applicationId) { + console.log('Getting WSO2 Application by id...'); + const application = await getWso2ApplicationById({ wso2Axios, applicationId }); + return application; + } + + if (!applicationSearchParameters) { + throw new Error('applicationSearchParameters is required for searching application'); + } + + console.log('Getting WSO2 API by search parameters...'); + const application = await findWso2Application({ + wso2Axios, + name: applicationSearchParameters.name, + }); + + if (!application) { + throw new Error('Cannot find the WSO2 application is related to this Custom Resource.'); + } + + return application; +}; + +export type FindWso2ApiSubscriptionArgs = { + wso2Axios: AxiosInstance; + apiId: string; + applicationId: string; +}; +export const findWso2ApiSubscription = async ({ + wso2Axios, + apiId, + applicationId, +}: FindWso2ApiSubscriptionArgs): Promise => { + const apil = await wso2Axios.get(`/api/am/store/v1/subscriptions`, { + params: { + apiId, + applicationId, + }, + }); + + const apiRes = apil.data.list; + + if (!apiRes) { + throw new Error('find subscription response is empty'); + } + + if (apiRes.length > 1) { + throw new Error( + `More than one subscription found for api '${apiId}' and application '${applicationId}' so we cannot determine it's id automatically`, + ); + } + + if (apiRes.length === 0) { + // eslint-disable-next-line no-undefined + return undefined; + } + + const existingSubscription = apiRes[0]; + console.log( + `Found existing WSO2 Subscription. subscriptionId=${existingSubscription.subscriptionId};`, + ); + + return existingSubscription; +}; + +export type GetWso2ApiSubscriptionByIdArgs = { + wso2Axios: AxiosInstance; + subscriptionId: string; +}; + +const getWso2ApiSubscriptionById = async ({ + wso2Axios, + subscriptionId, +}: GetWso2ApiSubscriptionByIdArgs): Promise => { + const res = await wso2Axios.get( + `/api/am/store/v1/subscriptions/${subscriptionId}`, + ); + return res.data; +}; + +export type CreateWso2ApiSubscriptionArgs = Pick & { + wso2Axios: AxiosInstance; + apiId: string; + applicationId: string; + throttlingPolicy: Wso2SubscriptionInfo['throttlingPolicy']; +}; + +export const createWso2ApiSubscription = async ({ + wso2Axios, + apiId, + applicationId, + throttlingPolicy, + retryOptions, +}: CreateWso2ApiSubscriptionArgs): Promise => { + const payload: Wso2SubscriptionDefinition = { + applicationId, + apiId, + throttlingPolicy, + }; + + const res = await backOff( + async () => wso2Axios.post(`/api/am/store/v1/subscriptions`, payload), + retryOptions?.mutationRetries, + ); + + // wait for Application to be created by retrying checks + await backOff(async () => { + await getWso2ApiSubscriptionById({ + wso2Axios, + subscriptionId: res.data.subscriptionId, + }); + }, retryOptions?.checkRetries); + + return res.data; +}; + +export type UpdateWso2ApiSubscriptionArgs = CreateWso2ApiSubscriptionArgs & { + subscriptionId: string; +}; + +export const updateWso2ApiSubscription = async ({ + wso2Axios, + subscriptionId, + apiId, + applicationId, + throttlingPolicy, + retryOptions, +}: UpdateWso2ApiSubscriptionArgs): Promise => { + const payload: Wso2SubscriptionDefinition = { + applicationId, + apiId, + throttlingPolicy, + }; + + const res = await backOff( + async () => + wso2Axios.put( + `/api/am/store/v1/subscriptions/${subscriptionId}`, + payload, + ), + retryOptions?.mutationRetries, + ); + + return res.data; +}; + +export type RemoveWso2ApiSubscriptionArgs = Pick & { + wso2Axios: AxiosInstance; + subscriptionId: string; +}; + +export const removeWso2ApiSubscription = async ({ + wso2Axios, + subscriptionId, + retryOptions, +}: RemoveWso2ApiSubscriptionArgs): Promise => { + await backOff( + async () => + wso2Axios.delete(`/api/am/store/v1/subscriptions/${subscriptionId}`, { + validateStatus(status) { + // If it returns 404, the api is already deleted + return status === 200 || status === 404; + }, + }), + retryOptions?.mutationRetries, + ); +}; diff --git a/lib/src/wso2/wso2-api-subscription/types.ts b/lib/src/wso2/wso2-api-subscription/types.ts new file mode 100644 index 0000000..27b7eff --- /dev/null +++ b/lib/src/wso2/wso2-api-subscription/types.ts @@ -0,0 +1,15 @@ +import { Wso2BaseProperties } from '../types'; + +export type Wso2ApiSubscriptionProps = Wso2BaseProperties & { + apiId?: string; + apiSearchParameters?: { + name: string; + version: string; + context: string; + }; + applicationId?: string; + applicationSearchParameters?: { + name: string; + }; + throttlingPolicy: 'Unlimited' | 'Gold' | 'Silver' | 'Bronze' | string; +}; diff --git a/lib/src/wso2/wso2-api-subscription/v1/types.ts b/lib/src/wso2/wso2-api-subscription/v1/types.ts new file mode 100644 index 0000000..6107f70 --- /dev/null +++ b/lib/src/wso2/wso2-api-subscription/v1/types.ts @@ -0,0 +1,71 @@ +import { ApiFromListV1 } from '../../wso2-api/v1/types'; +import { Wso2ApplicationInfo } from '../../wso2-application/v1/types'; + +export type Wso2SubscriptionInfo = Wso2SubscriptionDefinition & { + /** + * Subscription Id + * @example 123-456-789 + */ + subscriptionId: string; +}; + +export type Wso2SubscriptionDefinition = { + /** + * The UUID of the subscription + */ + subscriptionId?: string; + + /** + * The UUID of the application + */ + applicationId: string; + + /** + * The unique identifier of the API. + */ + apiId: string; + + apiInfo?: ApiFromListV1; + + applicationInfo?: Wso2ApplicationInfo; + + throttlingPolicy: 'Unlimited' | 'Bronze' | 'Silver' | 'Gold' | string; + + requestedThrottlingPolicy?: string; + + status?: + | 'BLOCKED' + | 'PROD_ONLY_BLOCKED' + | 'UNBLOCKED' + | 'ON_HOLD' + | 'REJECTED' + | 'TIER_UPDATE_PENDING'; + + /** + * A url and other parameters the subscriber can be redirected. + */ + redirectionParams?: string; +}; + +export type Wso2SubscriptionList = { + /** + * Number of Subscriptions returned. + */ + count?: number; + list?: Array; + pagination?: Pagination; +}; + +export type Pagination = { + offset?: number; + limit?: number; + total?: number; + /** + * Link to the next subset of resources qualified. Empty if no more resources are to be returned. + */ + next?: string; + /** + * Link to the previous subset of resources qualified. Empty if current subset is the first subset returned. + */ + previous?: string; +}; diff --git a/lib/src/wso2/wso2-api-subscription/wso2-api-subscription.test.ts b/lib/src/wso2/wso2-api-subscription/wso2-api-subscription.test.ts new file mode 100644 index 0000000..5041aff --- /dev/null +++ b/lib/src/wso2/wso2-api-subscription/wso2-api-subscription.test.ts @@ -0,0 +1,36 @@ +import { App, Stack } from 'aws-cdk-lib/core'; +import { Template } from 'aws-cdk-lib/assertions'; + +import { Wso2ApiSubscriptionProps } from './types'; +import { Wso2ApiSubscription } from './wso2-api-subscription'; + +describe('wso2-subscription-construct', () => { + it('minimal wso2 api', async () => { + const app = new App(); + const stack = new Stack(app); + + const testProps1 = testProps(); + const wso2Subscription = new Wso2ApiSubscription(stack, 'wso2', testProps1); + + expect(wso2Subscription.customResourceFunction).toBeDefined(); + + const template = Template.fromStack(stack); + + template.hasResourceProperties('Custom::Wso2ApiSubscription', { + // Should forward the props to the custom resource + ...testProps1, + }); + }); +}); + +const testProps = (): Wso2ApiSubscriptionProps => { + return { + wso2Config: { + baseApiUrl: 'http://localhost:8080/wso2', + credentialsSecretId: 'arn::creds', + }, + apiId: '1111-2222', + applicationId: '3333-4444', + throttlingPolicy: 'Unlimited', + }; +}; diff --git a/lib/src/wso2/wso2-api-subscription/wso2-api-subscription.ts b/lib/src/wso2/wso2-api-subscription/wso2-api-subscription.ts new file mode 100644 index 0000000..a4da447 --- /dev/null +++ b/lib/src/wso2/wso2-api-subscription/wso2-api-subscription.ts @@ -0,0 +1,122 @@ +import { Construct } from 'constructs'; +import { CustomResource, RemovalPolicy } from 'aws-cdk-lib/core'; +import { IFunction } from 'aws-cdk-lib/aws-lambda'; +import z from 'zod'; + +import { addLambdaAndProviderForWso2Operations } from '../utils-cdk'; + +import { Wso2ApiSubscriptionProps } from './types'; + +/** + * WSO2 API CDK construct for creating a WSO2 subscription from one application to an API + * This construct is related to one "physical" subscription in WSO2. + * + * The internal implementation tries to protect itself from various scenarios where larger or more complex + * WSO2 clusters might lead to out-of-order or delays in operations that happen asynchronously after the API + * accepts the requests, so for every mutation, there is a check to verify sanity. + * + * @example + * + * const wso2Api = new Wso2Api(...); + * const wso2Application = new Wso2Application(...); + * + * const wso2ApiSubscription = new Wso2ApiSubscription(this, 'Wso2ApiSubscription', { + * apiId: wso2Api.wso2ApiId, + * applicationId: wso2Application.wso2ApplicationId, + * }); + * + * --- + * + * const wso2ApiSubscription = new Wso2ApiSubscription(this, 'Wso2ApiSubscription', { + * apiSearchParameters: { + * name: 'MyApi', + * version: 'v1', + * context: 'my-api', + * }, + * applicationSearchParameters: { + * applicationName: 'MyApplication', + * }, + * }); + */ +export class Wso2ApiSubscription extends Construct { + readonly customResourceFunction: IFunction; + + readonly wso2SubscriptionId: string; + + constructor(scope: Construct, id: string, props: Wso2ApiSubscriptionProps) { + super(scope, id); + + validateProps(props); + + // Do as much of the logic in the construct as possible and leave only + // the minimal complexity to the Lambda Custom Resource as it's harder + // to debug and eventual errors will rollback the entire stack and will + // make the feedback cycle much longer. + + // Keep this construct stateless (don't access WSO2 apis) and + // leave the stateful part to the Lambda Custom Resource (accessing WSO2 apis etc) + + const { customResourceProvider, customResourceFunction } = + addLambdaAndProviderForWso2Operations({ + scope: this, + id: 'Wso2ApiSubscription', + props, + baseDir: __dirname, + }); + + // eslint-disable-next-line no-new + const resource = new CustomResource(this, 'Wso2ApiSubscription-custom-resource', { + serviceToken: customResourceProvider.serviceToken, + properties: props, + resourceType: 'Custom::Wso2ApiSubscription', + removalPolicy: props.removalPolicy ?? RemovalPolicy.RETAIN, + }); + + this.customResourceFunction = customResourceFunction.nodeJsFunction; + + // TODO: check for a better way to retrieve the subscription id + // https://github.com/aws-samples/aws-cdk-examples/discussions/641 + this.wso2SubscriptionId = resource.getAtt('SubscriptionId').toString(); + } +} + +export const validateProps = (props: Wso2ApiSubscriptionProps): void => { + const apiSchema = z.union([ + z.object({ + apiId: z.string(), + }), + z.object({ + apiSearchParameters: z.object({ + name: z.string(), + version: z.string(), + context: z.string(), + }), + }), + ]); + + const applicationSchema = z.union([ + z.object({ + applicationId: z.string(), + }), + z.object({ + applicationSearchParameters: z.object({ + name: z.string(), + }), + }), + ]); + + const schema = z + .object({ + wso2Config: z.object({ + baseApiUrl: z.string(), + credentialsSecretId: z.string(), + tenant: z.string().optional(), + apiVersion: z.string().optional(), + credentialsSecretKMSKeyId: z.string().optional(), + }), + }) + .and(apiSchema) + .and(applicationSchema); + + schema.parse(props); +}; diff --git a/lib/src/wso2/wso2-api/handler/index.test.ts b/lib/src/wso2/wso2-api/handler/index.test.ts index e3270b1..780aa67 100644 --- a/lib/src/wso2/wso2-api/handler/index.test.ts +++ b/lib/src/wso2/wso2-api/handler/index.test.ts @@ -3,12 +3,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import nock from 'nock'; -import { mockClient } from 'aws-sdk-client-mock'; -import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; import { petstoreOpenapi } from '../__tests__/petstore'; import { ApiFromListV1, PublisherPortalAPIv1, Wso2ApiDefinitionV1 } from '../v1/types'; import { Wso2ApiCustomResourceProperties } from '../types'; +import { nockBasicWso2SDK } from '../../__tests__/wso2-utils'; import { Wso2ApiCustomResourceEvent, handler } from './index'; @@ -44,7 +43,7 @@ describe('wso2 custom resource lambda', () => { }); it('basic wso2 api create', async () => { - nockBasicWso2SDK(); + nockBasicWso2SDK(baseWso2Url); // api list mock nock(baseWso2Url) @@ -87,7 +86,7 @@ describe('wso2 custom resource lambda', () => { }); it('wso2 api create and PUBLISH', async () => { - nockBasicWso2SDK(); + nockBasicWso2SDK(baseWso2Url); // api list mock nock(baseWso2Url) @@ -134,7 +133,7 @@ describe('wso2 custom resource lambda', () => { }); it('basic wso2 api update', async () => { - nockBasicWso2SDK(); + nockBasicWso2SDK(baseWso2Url); // api list mock const testDefs: Wso2ApiDefinitionV1 = { @@ -182,7 +181,7 @@ describe('wso2 custom resource lambda', () => { }); it('basic wso2 api change on UPDATE operation', async () => { - nockBasicWso2SDK(); + nockBasicWso2SDK(baseWso2Url); // api list mock const testDefs: Wso2ApiDefinitionV1 = { @@ -227,7 +226,7 @@ describe('wso2 custom resource lambda', () => { }); it('should pass with success if wso2 answers properly after a few retries', async () => { - nockBasicWso2SDK(); + nockBasicWso2SDK(baseWso2Url); // api list mock const testDefs: Wso2ApiDefinitionV1 = { @@ -303,7 +302,7 @@ describe('wso2 custom resource lambda', () => { }); it('should fail after retrying checking WSO2 api for a few times', async () => { - nockBasicWso2SDK(); + nockBasicWso2SDK(baseWso2Url); // api list mock const testDefs: Wso2ApiDefinitionV1 = { @@ -363,7 +362,7 @@ describe('wso2 custom resource lambda', () => { }); it('basic wso2 api delete on DELETE operation', async () => { - nockBasicWso2SDK(); + nockBasicWso2SDK(baseWso2Url); // api update mock nock(baseWso2Url) @@ -382,7 +381,7 @@ describe('wso2 custom resource lambda', () => { }); it('should return success when api does not exists on DELETE operation', async () => { - nockBasicWso2SDK(); + nockBasicWso2SDK(baseWso2Url); // api update mock nock(baseWso2Url) @@ -480,32 +479,6 @@ describe('wso2 custom resource lambda', () => { retryOptions: testRetryOptions, }; - const nockBasicWso2SDK = (): void => { - const secretMock = mockClient(SecretsManagerClient); - secretMock.on(GetSecretValueCommand).resolves({ - SecretBinary: Buffer.from(JSON.stringify({ user: 'user1', pwd: 'pwd1' })), - }); - - // register client mock - nock(baseWso2Url).post('/client-registration/v0.17/register').reply(200, { - clientId: 'clientId1', - clientSecret: 'clientSecret1', - }); - - // get token mock - nock(baseWso2Url).post('/oauth2/token').reply(200, { - access_token: '1111-1111-1111', - }); - - // mock server check - nock(baseWso2Url) - .get('/services/Version') - .reply( - 200, - 'WSO2 API Manager-3.2.0', - ); - }; - const nockAfterCreateUpdateAndChangeLifecycleStatusInWso2 = ( testDefs: Wso2ApiDefinitionV1, lifecycleAction?: string, diff --git a/lib/src/wso2/wso2-api/handler/index.ts b/lib/src/wso2/wso2-api/handler/index.ts index 0fdc6e0..aeea410 100644 --- a/lib/src/wso2/wso2-api/handler/index.ts +++ b/lib/src/wso2/wso2-api/handler/index.ts @@ -2,7 +2,6 @@ import { AxiosInstance } from 'axios'; import { CdkCustomResourceEvent, CdkCustomResourceResponse } from 'aws-lambda'; -import { PublisherPortalAPIv1 } from '../v1/types'; import { Wso2ApiCustomResourceProperties } from '../types'; import { prepareAxiosForWso2Calls } from '../../wso2-utils'; import { applyRetryDefaults, truncateStr } from '../../utils'; @@ -10,6 +9,7 @@ import { applyRetryDefaults, truncateStr } from '../../utils'; import { createUpdateAndChangeLifecycleStatusInWso2, findWso2Api, + getWso2ApiById, removeApiInWso2, } from './wso2-v1'; @@ -99,17 +99,18 @@ const createOrUpdateWso2Api = async ( console.log('Searching if API already exists in WSO2...'); const existingApi = await findWso2Api({ wso2Axios, - apiDefinition: event.ResourceProperties.apiDefinition, + apiName: event.ResourceProperties.apiDefinition.name, + apiVersion: event.ResourceProperties.apiDefinition.version, + apiContext: event.ResourceProperties.apiDefinition.context, wso2Tenant: event.ResourceProperties.wso2Config.tenant ?? '', }); let apiBeforeUpdate; - if (existingApi) { + if (existingApi && existingApi.id) { console.log( `Found existing WSO2 API. apiId=${existingApi.id}; name=${existingApi.name}; version=${existingApi.version} context=${existingApi.context}`, ); - const apir = await wso2Axios.get(`/api/am/publisher/v1/apis/${existingApi.id}`); - apiBeforeUpdate = apir.data as PublisherPortalAPIv1; + apiBeforeUpdate = await getWso2ApiById({ wso2Axios, wso2ApiId: existingApi.id }); } if (event.RequestType === 'Create' && existingApi && event.ResourceProperties.failIfExists) { diff --git a/lib/src/wso2/wso2-api/handler/wso2-v1.ts b/lib/src/wso2/wso2-api/handler/wso2-v1.ts index 5535d03..c25dbda 100644 --- a/lib/src/wso2/wso2-api/handler/wso2-v1.ts +++ b/lib/src/wso2/wso2-api/handler/wso2-v1.ts @@ -21,10 +21,12 @@ import { Wso2ApiProps } from '../types'; export const findWso2Api = async (args: { wso2Axios: AxiosInstance; - apiDefinition: Wso2ApiDefinitionV1; - wso2Tenant: string; + apiName: string; + apiVersion: string; + apiContext: string; + wso2Tenant?: string; }): Promise => { - const searchQuery = `name:${args.apiDefinition.name} version:${args.apiDefinition.version} context:${args.apiDefinition.context}`; + const searchQuery = `name:${args.apiName} version:${args.apiVersion} context:${args.apiContext}`; const res = await args.wso2Axios.get(`/api/am/publisher/v1/apis`, { params: { query: searchQuery }, @@ -45,12 +47,12 @@ export const findWso2Api = async (args: { // filter out apis that were found but don't match our tenant const filteredApis = apilist.list.filter((api) => { - if (api.name !== args.apiDefinition.name || api.version !== args.apiDefinition.version) { + if (api.name !== args.apiName || api.version !== args.apiVersion) { return false; } if (!api.context) return false; // 'api.context' may contain the full context name in wso2, which means '/t/[tenant]/[api context]' - if (api.context.endsWith(args.apiDefinition.context)) { + if (api.context.endsWith(args.apiContext)) { if (args.wso2Tenant) { return api.context.startsWith(`/t/${args.wso2Tenant}`); } @@ -69,10 +71,20 @@ export const findWso2Api = async (args: { return undefined; } throw new Error( - `Cannot determine which WSO2 API is related to this Custom Resource. More than 1 API with search query '${searchQuery}' matches. name=${args.apiDefinition.name} context=${args.apiDefinition.context} version=${args.apiDefinition.version} tenant=${args.wso2Tenant}`, + `Cannot determine which WSO2 API is related to this Custom Resource. More than 1 API with search query '${searchQuery}' matches. name=${args.apiName} context=${args.apiContext} version=${args.apiVersion} tenant=${args.wso2Tenant}`, ); }; +export const getWso2ApiById = async (args: { + wso2Axios: AxiosInstance; + wso2ApiId: string; +}): Promise => { + const apir = await args.wso2Axios.get( + `/api/am/publisher/v1/apis/${args.wso2ApiId}`, + ); + return apir.data; +}; + export type UpsertWso2Args = Pick & Required> & { wso2Axios: AxiosInstance; @@ -227,7 +239,9 @@ export const changeLifecycleStatusInWso2AndCheck = async ( console.log(''); console.log(`Checking if API is in lifecycle status '${args.lifecycleStatus}'...`); const fapi = await findWso2Api({ - apiDefinition: args.apiDefinition, + apiName: args.apiDefinition.name, + apiVersion: args.apiDefinition.version, + apiContext: args.apiDefinition.context, wso2Axios: args.wso2Axios, wso2Tenant: args.wso2Tenant, }); @@ -340,19 +354,20 @@ const checkApiExistsAndMatches = async ( console.log(''); console.log('Checking if API exists and matches the desired definition in WSO2...'); const searchApi = await findWso2Api({ - apiDefinition: args.apiDefinition, + apiName: args.apiDefinition.name, + apiVersion: args.apiDefinition.version, + apiContext: args.apiDefinition.context, wso2Axios: args.wso2Axios, wso2Tenant: args.wso2Tenant, }); - if (!searchApi) { + if (!searchApi || !searchApi.id) { throw new Error(`API couldn't be found on WSO2`); } console.log(`API '${searchApi.id}' found in WSO2 search`); - const apir = await args.wso2Axios.get(`/api/am/publisher/v1/apis/${searchApi.id}`); - const apiDetails = apir.data as PublisherPortalAPIv1; + const apiDetails = await getWso2ApiById({ wso2Axios: args.wso2Axios, wso2ApiId: searchApi.id }); if (!apiDetails.lastUpdatedTime) { throw new Error('lastUpdatedTime is null in api'); @@ -513,17 +528,18 @@ const checkApiDefAndOpenapiOverlap = async (args: UpsertWso2Args): Promise { const searchApi = await findWso2Api({ - apiDefinition: args.apiDefinition, + apiName: args.apiDefinition.name, + apiVersion: args.apiDefinition.version, + apiContext: args.apiDefinition.context, wso2Axios: args.wso2Axios, wso2Tenant: args.wso2Tenant, }); - if (!searchApi) { + if (!searchApi || !searchApi.id) { throw new Error('WSO2 API not found'); } - const apir = await args.wso2Axios.get(`/api/am/publisher/v1/apis/${searchApi.id}`); - const apiDetails = apir.data as PublisherPortalAPIv1; + const apiDetails = await getWso2ApiById({ wso2Axios: args.wso2Axios, wso2ApiId: searchApi.id }); const { isEquivalent } = checkWSO2Equivalence(apiDetails, args.apiDefinition); diff --git a/lib/src/wso2/wso2-api/wso2-api.ts b/lib/src/wso2/wso2-api/wso2-api.ts index 7fa0523..aea8b06 100644 --- a/lib/src/wso2/wso2-api/wso2-api.ts +++ b/lib/src/wso2/wso2-api/wso2-api.ts @@ -25,6 +25,8 @@ export class Wso2Api extends Construct { readonly openapiDocument: OpenAPIObject; + readonly wso2ApiId: string; + constructor(scope: Construct, id: string, props: Wso2ApiProps) { super(scope, id); @@ -51,7 +53,7 @@ export class Wso2Api extends Construct { // TODO check if large open api documents can be passed by Custom Resource properties // eslint-disable-next-line no-new - new CustomResource(this, `${id}-wso2api-custom-resource`, { + const resource = new CustomResource(this, `${id}-wso2api-custom-resource`, { serviceToken: customResourceProvider.serviceToken, properties: { wso2Config: props.wso2Config, @@ -67,6 +69,10 @@ export class Wso2Api extends Construct { this.apiDefinition = wso2ApiDefs; this.openapiDocument = props.openapiDocument; this.customResourceFunction = customResourceFunction.nodeJsFunction; + + // TODO: check for a better way to retrieve the api id + // https://github.com/aws-samples/aws-cdk-examples/discussions/641 + this.wso2ApiId = resource.getAtt('Wso2ApiId').toString(); } } diff --git a/lib/src/wso2/wso2-application/handler/index.test.ts b/lib/src/wso2/wso2-application/handler/index.test.ts index 4096e1d..916b9e6 100644 --- a/lib/src/wso2/wso2-application/handler/index.test.ts +++ b/lib/src/wso2/wso2-application/handler/index.test.ts @@ -3,11 +3,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import nock from 'nock'; -import { mockClient } from 'aws-sdk-client-mock'; -import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; import { Wso2ApplicationDefinition } from '../v1/types'; import { Wso2ApplicationCustomResourceProperties } from '../types'; +import { nockBasicWso2SDK } from '../../__tests__/wso2-utils'; import { Wso2ApplicationCustomResourceEvent, handler } from './index'; @@ -43,7 +42,7 @@ describe('wso2 application custom resource lambda', () => { }); it('wso2 application delete', async () => { - nockBasicWso2SDK(); + nockBasicWso2SDK(baseWso2Url); // application get mock nock(baseWso2Url) @@ -56,7 +55,7 @@ describe('wso2 application custom resource lambda', () => { }); it('basic wso2 application update', async () => { - nockBasicWso2SDK(); + nockBasicWso2SDK(baseWso2Url); const testDefs: Wso2ApplicationDefinition = testApplicationDefs(); @@ -89,7 +88,7 @@ describe('wso2 application custom resource lambda', () => { }); it('basic wso2 application create', async () => { - nockBasicWso2SDK(); + nockBasicWso2SDK(baseWso2Url); // api list mock nock(baseWso2Url) @@ -183,30 +182,4 @@ describe('wso2 application custom resource lambda', () => { applicationDefinition: testApplicationDefs(), retryOptions: testRetryOptions, }; - - const nockBasicWso2SDK = (): void => { - const secretMock = mockClient(SecretsManagerClient); - secretMock.on(GetSecretValueCommand).resolves({ - SecretBinary: Buffer.from(JSON.stringify({ user: 'user1', pwd: 'pwd1' })), - }); - - // register client mock - nock(baseWso2Url).post('/client-registration/v0.17/register').reply(200, { - clientId: 'clientId1', - clientSecret: 'clientSecret1', - }); - - // get token mock - nock(baseWso2Url).post('/oauth2/token').reply(200, { - access_token: '1111-1111-1111', - }); - - // mock server check - nock(baseWso2Url) - .get('/services/Version') - .reply( - 200, - 'WSO2 API Manager-3.2.0', - ); - }; }); diff --git a/lib/src/wso2/wso2-application/handler/index.ts b/lib/src/wso2/wso2-application/handler/index.ts index 235dbe0..2d4101d 100644 --- a/lib/src/wso2/wso2-application/handler/index.ts +++ b/lib/src/wso2/wso2-application/handler/index.ts @@ -5,10 +5,13 @@ import type { AxiosInstance } from 'axios'; import { prepareAxiosForWso2Calls } from '../../wso2-utils'; import { applyRetryDefaults, truncateStr } from '../../utils'; -import type { Wso2ApplicationInfo } from '../v1/types'; import type { Wso2ApplicationCustomResourceProperties } from '../types'; -import { createUpdateApplicationInWso2, removeApplicationInWso2 } from './wso2-v1'; +import { + createUpdateApplicationInWso2, + removeApplicationInWso2, + findWso2Application, +} from './wso2-v1'; export type Wso2ApplicationCustomResourceEvent = CdkCustomResourceEvent & { ResourceProperties: Wso2ApplicationCustomResourceProperties; @@ -90,22 +93,10 @@ const createOrUpdateWso2Application = async ( // find existing WSO2 application console.log('Searching if Application already exists in WSO2...'); - let existingApplication: Wso2ApplicationInfo | undefined; - const apil = await wso2Axios.get(`/api/am/store/v1/applications`, { - params: { query: event.ResourceProperties.applicationDefinition.name }, + const existingApplication = await findWso2Application({ + wso2Axios, + name: event.ResourceProperties.applicationDefinition.name, }); - const apiRes = apil.data.list as Wso2ApplicationInfo[]; - if (apiRes.length > 1) { - throw new Error( - `More than one Application with name '${event.ResourceProperties.applicationDefinition.name}' was found in WSO2 so we cannot determine it's id automatically`, - ); - } - if (apiRes.length === 1) { - existingApplication = apiRes[0]; - console.log( - `Found existing WSO2 Application. applicationId=${existingApplication.applicationId}; name=${existingApplication.name}`, - ); - } if ( event.RequestType === 'Create' && diff --git a/lib/src/wso2/wso2-application/handler/wso2-v1.ts b/lib/src/wso2/wso2-application/handler/wso2-v1.ts index ad12cac..f5e7442 100644 --- a/lib/src/wso2/wso2-application/handler/wso2-v1.ts +++ b/lib/src/wso2/wso2-application/handler/wso2-v1.ts @@ -65,7 +65,10 @@ export const createUpdateApplicationInWso2AndCheck = async ( // wait for Application to be created by retrying checks await backOff(async () => { - await args.wso2Axios.get(`/api/am/store/v1/applications/${dataRes.applicationId}`); + await getWso2ApplicationById({ + wso2Axios: args.wso2Axios, + applicationId: dataRes.applicationId!, + }); }, args.retryOptions.checkRetries); return dataRes.applicationId; @@ -85,10 +88,54 @@ export const createUpdateApplicationInWso2AndCheck = async ( // wait for Application to be created by retrying checks await backOff(async () => { - await args.wso2Axios.get( - `/api/am/store/v1/applications/${args.existingApplication?.applicationId}`, - ); + await getWso2ApplicationById({ + wso2Axios: args.wso2Axios, + applicationId: args.existingApplication!.applicationId!, + }); }, args.retryOptions.checkRetries); return args.existingApplication.applicationId; }; + +export const getWso2ApplicationById = async (args: { + wso2Axios: AxiosInstance; + applicationId: string; +}): Promise => { + const res = await args.wso2Axios.get( + `/api/am/store/v1/applications/${args.applicationId}`, + ); + return res.data; +}; + +export const findWso2Application = async (args: { + wso2Axios: AxiosInstance; + name: string; +}): Promise => { + const apil = await args.wso2Axios.get<{ list: Wso2ApplicationInfo[] }>( + `/api/am/store/v1/applications`, + { + params: { + query: args.name, + }, + }, + ); + const apiRes = apil.data.list; + + if (apiRes.length > 1) { + throw new Error( + `More than one Application with name '${args.name}' was found in WSO2 so we cannot determine it's id automatically`, + ); + } + + if (apiRes.length === 0) { + // eslint-disable-next-line no-undefined + return undefined; + } + + const existingApplication = apiRes[0]; + console.log( + `Found existing WSO2 Application. applicationId=${existingApplication.applicationId}; name=${existingApplication.name}`, + ); + + return existingApplication; +}; diff --git a/lib/src/wso2/wso2-application/wso2-application.ts b/lib/src/wso2/wso2-application/wso2-application.ts index c3e8d8b..27bb1f4 100644 --- a/lib/src/wso2/wso2-application/wso2-application.ts +++ b/lib/src/wso2/wso2-application/wso2-application.ts @@ -17,6 +17,8 @@ import { Wso2ApplicationCustomResourceProperties, Wso2ApplicationProps } from '. export class Wso2Application extends Construct { readonly customResourceFunction: IFunction; + readonly wso2ApplicationId: string; + constructor(scope: Construct, id: string, props: Wso2ApplicationProps) { super(scope, id); @@ -39,7 +41,7 @@ export class Wso2Application extends Construct { }); // eslint-disable-next-line no-new - new CustomResource(this, `${id}-wso2app-custom-resource`, { + const resource = new CustomResource(this, `${id}-wso2app-custom-resource`, { serviceToken: customResourceProvider.serviceToken, properties: { wso2Config: props.wso2Config, @@ -51,6 +53,10 @@ export class Wso2Application extends Construct { }); this.customResourceFunction = customResourceFunction.nodeJsFunction; + + // TODO: check for a better way to retrieve the application id + // https://github.com/aws-samples/aws-cdk-examples/discussions/641 + this.wso2ApplicationId = resource.getAtt('Wso2ApplicationId').toString(); } }