diff --git a/docs/features/parameters.md b/docs/features/parameters.md index 0947463769..1049b21553 100644 --- a/docs/features/parameters.md +++ b/docs/features/parameters.md @@ -66,6 +66,10 @@ This utility requires additional permissions to work as expected. | DynamoDB | **`DynamoDBProvider.get`** | **`dynamodb:GetItem`** | | DynamoDB | **`DynamoDBProvider.getMultiple`** | **`dynamodb:Query`** | | AppConfig | **`getAppConfig`**, **`AppConfigProvider.getAppConfig`** | **`appconfig:GetLatestConfiguration`** and **`appconfig:StartConfigurationSession`** | +| AppConfig | **`getConfig`** (AppConfig Agent) | **`appconfig:GetLatestConfiguration`** and **`appconfig:StartConfigurationSession`** | + +???+ note + When using `getConfig`, the permissions are used by the [AWS AppConfig Agent Lambda extension](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-lambda-extensions.html) via the function role. ### Fetching parameters @@ -139,6 +143,29 @@ The following will retrieve the latest version and store it in the cache. When using `getAppConfig`, the [underlying provider](#appconfigprovider) is cached. To fetch from different applications or environments, create separate `AppConfigProvider` instances for each application/environment combination. +### Fetching app configurations via AppConfig Agent + +If you use the [AWS AppConfig Agent Lambda extension](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-lambda-extensions.html), you can fetch application configurations from the agent's local HTTP endpoint using `getConfig`. + +```typescript hl_lines="1 4-8" title="Fetching a config from the AppConfig Agent" +--8<-- "examples/snippets/parameters/getConfigAppConfigAgent.ts" +``` + +???+ note + To use `getConfig` you must [add the AWS AppConfig Agent Lambda extension layer](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-lambda-extensions-versions.html) to your function. + +When `getConfig` detects that it's not running in AWS Lambda (the `AWS_LAMBDA_INITIALIZATION_TYPE` environment variable is not set), or when `POWERTOOLS_DEV` is enabled, it returns `undefined` without making any request - this is helpful for unit testing. +Local emulators that replicate the Lambda runtime environment (e.g. AWS SAM CLI) set the Lambda environment variables, so in those environments `getConfig` will attempt to call the agent. + +Unlike `getAppConfig`, which uses the AWS SDK, the agent handles caching, polling, and prefetching for you, so `getConfig` doesn't support the `maxAge` and `forceFetch` options and always fetches from the agent's local endpoint. + +When the requested configuration doesn't exist, `getConfig` returns `undefined` - you can use the nullish coalescing operator (`??`) to provide a fallback value, or set the [`throwOnMissing` option](#throwing-on-missing-values) to throw a `ParameterNotFoundError` instead. Any other failure to reach the agent or retrieve the value throws a `GetParameterError`. + +You can configure the agent's behavior using [the environment variables it exposes](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-lambda-extensions-config.html). +For example, you can reduce cold start latency by setting `AWS_APPCONFIG_EXTENSION_PREFETCH_LIST` to the path of your configuration (i.e. `/applications/my-app/environments/my-env/configurations/my-configuration`), so that the agent starts fetching it before your handler runs. + +If you configured the agent to run on a port other than the default `2772` using the `AWS_APPCONFIG_EXTENSION_HTTP_PORT` environment variable, `getConfig` will use that port. You can also pass a `timeout` option (in milliseconds, default `3000`) to control how long to wait for the agent to respond. + ## Advanced ### Adjusting cache TTL @@ -503,6 +530,22 @@ For when you want to mock the AWS SDK v3 client directly, we recommend using the --8<-- "examples/snippets/parameters/testingYourCodeClientHandler.ts" ``` +### Testing getConfig (AppConfig Agent) + +When not running in AWS Lambda, the `getConfig` function doesn't make any request, so your tests never need to mock the agent's HTTP endpoint. Without a local value configured, it behaves as if the configuration doesn't exist: it returns `undefined`, or throws a `ParameterNotFoundError` when the `throwOnMissing` option is set. + +If you want `getConfig` to return a value instead, set the `POWERTOOLS_APPCONFIG_AGENT_RETURN_VALUE` environment variable. Its value is treated as the agent response, so it goes through the same `transform` handling as a real response - for example, a JSON string works with `transform: 'json'`. + +=== "handler.test.ts" + ```typescript hl_lines="6-9" + --8<-- "examples/snippets/parameters/testingYourCodeAppConfigAgent.ts" + ``` + +=== "handler.ts" + ```typescript + --8<-- "examples/snippets/parameters/testingYourCodeAppConfigAgentHandler.ts" + ``` + ### Clearing cache Parameters utility caches all parameter values for performance and cost reasons. However, this can have unintended interference in tests using the same parameter name. diff --git a/examples/snippets/parameters/getConfigAppConfigAgent.ts b/examples/snippets/parameters/getConfigAppConfigAgent.ts new file mode 100644 index 0000000000..9a528dea38 --- /dev/null +++ b/examples/snippets/parameters/getConfigAppConfigAgent.ts @@ -0,0 +1,12 @@ +import { getConfig } from '@aws-lambda-powertools/parameters/appconfig-agent'; + +// Retrieve a configuration from the AppConfig Agent Lambda extension +const config = await getConfig('my-configuration', { + environment: 'my-env', + application: 'my-app', + transform: 'json', +}); + +export const handler = async (): Promise => { + console.log(config); +}; diff --git a/examples/snippets/parameters/testingYourCodeAppConfigAgent.ts b/examples/snippets/parameters/testingYourCodeAppConfigAgent.ts new file mode 100644 index 0000000000..adb0e79e76 --- /dev/null +++ b/examples/snippets/parameters/testingYourCodeAppConfigAgent.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('Function tests', () => { + beforeEach(() => { + vi.resetModules(); + vi.stubEnv( + 'POWERTOOLS_APPCONFIG_AGENT_RETURN_VALUE', + JSON.stringify({ featureX: true }) + ); + }); + + it('returns the expected response', async () => { + // Prepare + const { handler } = await import( + './testingYourCodeAppConfigAgentHandler.js' + ); + + // Act + const response = await handler({}, {}); + + // Assess + expect(response).toEqual({ featureX: true }); + }); +}); diff --git a/examples/snippets/parameters/testingYourCodeAppConfigAgentHandler.ts b/examples/snippets/parameters/testingYourCodeAppConfigAgentHandler.ts new file mode 100644 index 0000000000..231eb7cb02 --- /dev/null +++ b/examples/snippets/parameters/testingYourCodeAppConfigAgentHandler.ts @@ -0,0 +1,14 @@ +import { getConfig } from '@aws-lambda-powertools/parameters/appconfig-agent'; + +const config = await getConfig('my-configuration', { + application: 'my-app', + environment: 'my-env', + transform: 'json', +}); + +export const handler = async ( + _event: unknown, + _context: unknown +): Promise => { + return config; +}; diff --git a/packages/parameters/README.md b/packages/parameters/README.md index 5d1566bb77..80d9acbff0 100644 --- a/packages/parameters/README.md +++ b/packages/parameters/README.md @@ -10,6 +10,7 @@ You can use the package in both TypeScript and JavaScript code bases. - [Getting secrets from Amazon Secrets Manager](#getting-secrets-from-amazon-secrets-manager) - [Retrieving values from Amazon DynamoDB](#retrieving-values-from-amazon-dynamodb) - [Fetching configs from AWS AppConfig](#fetching-configs-from-aws-appconfig) + - [Fetching configs from the AWS AppConfig Agent](#fetching-configs-from-the-aws-appconfig-agent) - [Contribute](#contribute) - [Roadmap](#roadmap) - [Connect](#connect) @@ -195,6 +196,27 @@ export const handler = async (): Promise => { Check the [docs](https://docs.aws.amazon.com/powertools/typescript/latest/features/parameters/#fetching-app-configurations) for more examples, and [the advanced section](https://docs.aws.amazon.com/powertools/typescript/latest/features/parameters/#advanced) for details about caching, transforms, customizing the underlying SDK, and more. +### Fetching configs from the AWS AppConfig Agent + +If you use the [AWS AppConfig Agent Lambda extension](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-lambda-extensions.html), you can fetch application configurations from the agent's local HTTP endpoint using the `getConfig` function: + +```ts +import { getConfig } from '@aws-lambda-powertools/parameters/appconfig-agent'; + +// Retrieve a configuration from the AppConfig Agent Lambda extension +const config = await getConfig('my-configuration', { + environment: 'my-env', + application: 'my-app', + transform: 'json', +}); + +export const handler = async (): Promise => { + console.log(config); +}; +``` + +Since caching, polling, and prefetching are handled by the agent, `getConfig` always fetches from the agent's local endpoint and doesn't require the AWS SDK. Check the [docs](https://docs.aws.amazon.com/powertools/typescript/latest/features/parameters/#fetching-app-configurations-via-appconfig-agent) for more details. + ## Contribute If you are interested in contributing to this project, please refer to our [Contributing Guidelines](https://github.com/aws-powertools/powertools-lambda-typescript/blob/main/CONTRIBUTING.md). diff --git a/packages/parameters/package.json b/packages/parameters/package.json index 8227a7d174..b3877db66a 100644 --- a/packages/parameters/package.json +++ b/packages/parameters/package.json @@ -81,6 +81,14 @@ "import": "./lib/esm/appconfig/index.js", "require": "./lib/cjs/appconfig/index.js" }, + "./appconfig-agent/types": { + "import": "./lib/esm/types/AppConfigAgent.js", + "require": "./lib/cjs/types/AppConfigAgent.js" + }, + "./appconfig-agent": { + "import": "./lib/esm/appconfig-agent/index.js", + "require": "./lib/cjs/appconfig-agent/index.js" + }, "./errors": { "import": "./lib/esm/errors.js", "require": "./lib/cjs/errors.js" @@ -128,6 +136,14 @@ "lib/cjs/appconfig/index.d.ts", "lib/esm/appconfig/index.d.ts" ], + "appconfig-agent/types": [ + "./lib/cjs/types/AppConfigAgent.d.ts", + "./lib/esm/types/AppConfigAgent.d.ts" + ], + "appconfig-agent": [ + "lib/cjs/appconfig-agent/index.d.ts", + "lib/esm/appconfig-agent/index.d.ts" + ], "errors": [ "lib/cjs/errors.d.ts", "lib/esm/errors.d.ts" diff --git a/packages/parameters/src/appconfig-agent/getConfig.ts b/packages/parameters/src/appconfig-agent/getConfig.ts new file mode 100644 index 0000000000..02661a04ce --- /dev/null +++ b/packages/parameters/src/appconfig-agent/getConfig.ts @@ -0,0 +1,192 @@ +import { + getServiceName, + getStringFromEnv, + isRunningInLambda, +} from '@aws-lambda-powertools/commons/utils/env'; +import { transformValue } from '../base/transformValue.js'; +import { GetParameterError, ParameterNotFoundError } from '../errors.js'; +import type { + AppConfigAgentGetConfigOutput, + GetConfigOptions, +} from '../types/AppConfigAgent.js'; + +/** + * Retrieve a configuration from the AWS AppConfig Agent Lambda extension. + * + * The [AWS AppConfig Agent](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-lambda-extensions.html) + * runs as a Lambda extension inside your execution environment and exposes a local HTTP endpoint that this function calls to + * retrieve configurations. To use this function you must add the AWS AppConfig Agent Lambda extension layer to your function. + * + * Unlike the SDK-based `getAppConfig` function, caching, polling, and prefetching are handled entirely by the agent, so this + * function always fetches from the agent's local endpoint and doesn't support the `maxAge` and `forceFetch` options. You can + * configure the agent's behavior, including caching and prefetching, using the [environment variables exposed by the agent](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-lambda-extensions-config.html). + * + * When the requested configuration does not exist, the function returns `undefined`. You can use the `throwOnMissing` + * option to throw a `ParameterNotFoundError` instead, or use the nullish coalescing operator (`??`) to provide a fallback value. + * + * When the function detects that it's not running in AWS Lambda (the `AWS_LAMBDA_INITIALIZATION_TYPE` environment variable + * is not set), or when running with `POWERTOOLS_DEV` enabled, it doesn't make any request. In this case, if the + * `POWERTOOLS_APPCONFIG_AGENT_RETURN_VALUE` environment variable is set, its value is treated as the agent response and goes + * through the same transform handling; otherwise the function returns `undefined`, or throws a `ParameterNotFoundError` when + * the `throwOnMissing` option is set. This is helpful for unit testing and local development. Note that local emulators that + * replicate the Lambda runtime environment (e.g., AWS SAM CLI) set the Lambda environment variables, so in those environments + * the function will attempt to call the agent. + * + * **Basic usage** + * + * @example + * ```typescript + * import { getConfig } from '@aws-lambda-powertools/parameters/appconfig-agent'; + * + * const config = await getConfig('my-configuration', { + * application: 'my-app', + * environment: 'my-env', + * }); + * + * export const handler = async (): Promise => { + * // Use the config variable as needed + * console.log(config); + * }; + * ``` + * + * **Transformations** + * + * For configurations stored as freeform JSON or feature flags, you can use the `transform` argument set to `json` for deserialization. + * This will return a JavaScript object instead of a string. + * + * @example + * ```typescript + * import { getConfig } from '@aws-lambda-powertools/parameters/appconfig-agent'; + * + * const config = await getConfig('my-configuration', { + * application: 'my-app', + * environment: 'my-env', + * transform: 'json', + * }); + * + * export const handler = async (): Promise => { + * // Use the config variable as needed + * console.log(config); + * }; + * ``` + * + * For configurations stored as base64-encoded binary data, you can use the `transform` argument set to `binary` for decoding. + * This will return a decoded string. + * + * @example + * ```typescript + * import { getConfig } from '@aws-lambda-powertools/parameters/appconfig-agent'; + * + * const config = await getConfig('my-configuration', { + * application: 'my-app', + * environment: 'my-env', + * transform: 'binary', + * }); + * + * export const handler = async (): Promise => { + * // Use the config variable as needed + * console.log(config); + * }; + * ``` + * + * By default, the function calls the agent on port `2772`. If you configured the agent to run on a different port using the + * `AWS_APPCONFIG_EXTENSION_HTTP_PORT` environment variable, the function will use that port instead. + * + * @see https://docs.aws.amazon.com/powertools/typescript/latest/features/parameters/ + * + * @param name - The name of the configuration profile or the configuration profile ID + * @param options - Options to configure the retrieval + * @param options.environment - The environment ID or the environment name + * @param options.application - The application ID or the application name, if not provided it will be inferred from the service name in the environment (`POWERTOOLS_SERVICE_NAME`) + * @param options.transform - Optional transform to be applied, can be `json` or `binary` + * @param options.timeout - Optional timeout in milliseconds for the request to the AWS AppConfig Agent (default: `3000`) + * @param options.throwOnMissing - Optional flag to throw a `ParameterNotFoundError` when the configuration does not exist (default: `false`) + */ +/** + * Fetch a configuration from the AWS AppConfig Agent local HTTP endpoint. + * + * Returns the raw response body, or `undefined` when the configuration does not + * exist and `throwOnMissing` is not set. + * + * @param name - The name of the configuration profile or the configuration profile ID + * @param options - Options to configure the retrieval, see {@link getConfig | `getConfig()`} + */ +const fetchConfigFromAgent = async ( + name: string, + options: GetConfigOptions +): Promise => { + const application = options.application ?? getServiceName(); + if (application.trim().length === 0) { + throw new GetParameterError( + 'Application name is not defined or POWERTOOLS_SERVICE_NAME is not set' + ); + } + + const port = getStringFromEnv({ + key: 'AWS_APPCONFIG_EXTENSION_HTTP_PORT', + defaultValue: '2772', + }); + + try { + const res = await fetch( + `http://localhost:${port}/applications/${encodeURIComponent(application)}/environments/${encodeURIComponent(options.environment)}/configurations/${encodeURIComponent(name)}`, + { + signal: AbortSignal.timeout(options.timeout ?? 3000), + } + ); + const value = await res.text(); + if (res.status === 404) { + if (options.throwOnMissing) { + throw new ParameterNotFoundError(`Configuration ${name} not found`); + } + return undefined; + } + if (!res.ok) { + throw new GetParameterError( + `Failed to retrieve configuration from AppConfig Agent: ${res.status} ${value}` + ); + } + return value; + } catch (error) { + if (error instanceof GetParameterError) throw error; + throw new GetParameterError((error as Error).message, { cause: error }); + } +}; + +const getConfig = async < + ExplicitUserProvidedType = undefined, + InferredFromOptionsType extends GetConfigOptions = GetConfigOptions, +>( + name: string, + options: InferredFromOptionsType & GetConfigOptions +): Promise< + AppConfigAgentGetConfigOutput< + ExplicitUserProvidedType, + InferredFromOptionsType + > +> => { + let value: string | undefined; + if (isRunningInLambda()) { + value = await fetchConfigFromAgent(name, options); + } else { + const localValue = getStringFromEnv({ + key: 'POWERTOOLS_APPCONFIG_AGENT_RETURN_VALUE', + defaultValue: '', + }); + if (localValue === '' && options.throwOnMissing) { + throw new ParameterNotFoundError(`Configuration ${name} not found`); + } + value = localValue === '' ? undefined : localValue; + } + + return ( + value !== undefined && options.transform + ? transformValue(value, options.transform, true, name) + : value + ) as AppConfigAgentGetConfigOutput< + ExplicitUserProvidedType, + InferredFromOptionsType + >; +}; + +export { getConfig }; diff --git a/packages/parameters/src/appconfig-agent/index.ts b/packages/parameters/src/appconfig-agent/index.ts new file mode 100644 index 0000000000..ad24693b35 --- /dev/null +++ b/packages/parameters/src/appconfig-agent/index.ts @@ -0,0 +1 @@ +export { getConfig } from './getConfig.js'; diff --git a/packages/parameters/src/types/AppConfigAgent.ts b/packages/parameters/src/types/AppConfigAgent.ts new file mode 100644 index 0000000000..ad80611885 --- /dev/null +++ b/packages/parameters/src/types/AppConfigAgent.ts @@ -0,0 +1,74 @@ +import type { JSONValue } from '@aws-lambda-powertools/commons/types'; +import type { getConfig } from '../appconfig-agent/getConfig.js'; +import type { GetMaybeUndefined } from './BaseProvider.js'; + +/** + * Options for the {@link getConfig | `getConfig()`} function. + * + * @property environment - The environment ID or the environment name. + * @property application - The application ID or the application name, if not provided it will be inferred from the service name in the environment. + * @property transform - Optional transform to be applied, can be `json` or `binary`. + * @property timeout - Optional timeout in milliseconds for the request to the AWS AppConfig Agent (default: `3000`). + */ +type GetConfigOptions = { + /** + * The environment ID or the environment name. + */ + environment: string; + /** + * The application ID or the application name, if not provided it will be inferred from the service name in the environment (`POWERTOOLS_SERVICE_NAME`). + */ + application?: string; + /** + * Optional transform to be applied, can be `json` or `binary`. + */ + transform?: 'json' | 'binary'; + /** + * Optional timeout in milliseconds for the request to the AWS AppConfig Agent (default: `3000`). + */ + timeout?: number; + /** + * Optional flag to throw a `ParameterNotFoundError` when the configuration does not exist (default: `false`). + * + * By default, a missing configuration returns `undefined`. + */ + throwOnMissing?: boolean; +}; + +/** + * Generic output type for the {@link getConfig | `getConfig()`} function. + * + * When an explicit type is provided, it takes precedence. Otherwise, the type is inferred + * from the `transform` option: `json` returns a {@link JSONValue | `JSONValue`}, everything + * else (`binary` or no transform) returns a `string`. + */ +type AppConfigAgentGetOutput< + ExplicitUserProvidedType = undefined, + InferredFromOptionsType = undefined, +> = undefined extends ExplicitUserProvidedType + ? InferredFromOptionsType extends { transform: 'json' } + ? JSONValue + : string + : ExplicitUserProvidedType; + +/** + * Return type of the {@link getConfig | `getConfig()`} function. + * + * Combines {@link AppConfigAgentGetOutput | `AppConfigAgentGetOutput`} with + * {@link GetMaybeUndefined | `GetMaybeUndefined`}: the value type is inferred from the + * `transform` option (or the explicit type parameter), and `undefined` is excluded from + * the union when the `throwOnMissing` option is set to `true`. + */ +type AppConfigAgentGetConfigOutput< + ExplicitUserProvidedType = undefined, + InferredFromOptionsType = undefined, +> = GetMaybeUndefined< + AppConfigAgentGetOutput, + InferredFromOptionsType +>; + +export type { + AppConfigAgentGetConfigOutput, + AppConfigAgentGetOutput, + GetConfigOptions, +}; diff --git a/packages/parameters/tests/e2e/appConfigAgent.test.functionCode.ts b/packages/parameters/tests/e2e/appConfigAgent.test.functionCode.ts new file mode 100644 index 0000000000..032915ca06 --- /dev/null +++ b/packages/parameters/tests/e2e/appConfigAgent.test.functionCode.ts @@ -0,0 +1,77 @@ +import { getConfig } from '../../src/appconfig-agent/index.js'; + +const application = process.env.APPLICATION_NAME ?? 'my-app'; +const environment = process.env.ENVIRONMENT_NAME ?? 'my-env'; +const freeFormJsonName = process.env.FREEFORM_JSON_NAME ?? 'freeform-json'; +const freeFormYamlName = process.env.FREEFORM_YAML_NAME ?? 'freeform-yaml'; +const freeFormBase64encodedPlainText = + process.env.FREEFORM_BASE64_ENCODED_PLAIN_TEXT_NAME ?? 'freeform-plain-text'; +const featureFlagName = process.env.FEATURE_FLAG_NAME ?? 'feature-flag'; + +// The first request for each configuration makes the agent call AppConfig on +// the spot, so we allow more than the default timeout to absorb cold starts. +const baseOptions = { + application, + environment, + timeout: 5000, +} as const; + +// Captures the name of the error thrown when requesting a configuration that +// doesn't exist with `throwOnMissing` enabled, so the test file can assert on it. +const getMissingConfigErrorName = async (): Promise => { + try { + await getConfig('does-not-exist', { ...baseOptions, throwOnMissing: true }); + return 'no error thrown'; + } catch (error) { + return (error as Error).name; + } +}; + +// Requests a configuration that doesn't exist without `throwOnMissing`, and +// reports whether the default behavior returned undefined. +const isMissingConfigUndefined = async (): Promise => { + const value = await getConfig('does-not-exist', baseOptions); + return value === undefined; +}; + +/** + * The handler returns the results of each `getConfig` call as the invocation + * payload, so the test file can assert on them directly. If any of the calls + * unexpectedly throws, the function crashes and the invocation reports a + * `FunctionError`, failing the test suite loudly. + */ +export const handler = async (): Promise> => { + const [ + raw, + json, + binary, + featureFlag, + missingConfigErrorName, + missingConfigIsUndefined, + ] = await Promise.all([ + // Test 1 - get a configuration as-is (no transformation - should return a string) + getConfig(freeFormYamlName, baseOptions), + // Test 2 - get a free-form JSON and apply json transformation (should return an object) + getConfig(freeFormJsonName, { ...baseOptions, transform: 'json' }), + // Test 3 - get a free-form base64-encoded plain text and apply binary transformation (should return a decoded string) + getConfig(freeFormBase64encodedPlainText, { + ...baseOptions, + transform: 'binary', + }), + // Test 4 - get a feature flag and apply json transformation (should return an object with the evaluated flag values) + getConfig(featureFlagName, { ...baseOptions, transform: 'json' }), + // Test 5 - get a configuration that does not exist with throwOnMissing (should throw a ParameterNotFoundError) + getMissingConfigErrorName(), + // Test 6 - get a configuration that does not exist (should return undefined) + isMissingConfigUndefined(), + ]); + + return { + raw, + json, + binary, + featureFlag, + missingConfigErrorName, + missingConfigIsUndefined, + }; +}; diff --git a/packages/parameters/tests/e2e/appConfigAgent.test.ts b/packages/parameters/tests/e2e/appConfigAgent.test.ts new file mode 100644 index 0000000000..7a569d51ef --- /dev/null +++ b/packages/parameters/tests/e2e/appConfigAgent.test.ts @@ -0,0 +1,262 @@ +import { join } from 'node:path'; +import { + getArchitectureKey, + TestStack, +} from '@aws-lambda-powertools/testing-utils'; +import { TestNodejsFunction } from '@aws-lambda-powertools/testing-utils/resources/lambda'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { toBase64 } from '@smithy/util-base64'; +import { fromUtf8, toUtf8 } from '@smithy/util-utf8'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { TestAppConfigWithProfiles } from '../helpers/resources.js'; +import { RESOURCE_NAME_PREFIX } from './constants.js'; + +/** + * ARNs of the AWS AppConfig Agent Lambda extension layer, see: + * https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-lambda-extensions-versions.html + * + * The layer publisher account ID differs per region, so we keep a map of the regions + * where we run the end-to-end tests. + */ +const appConfigAgentLayerArns = { + 'us-east-1': { + x86_64: + 'arn:aws:lambda:us-east-1:027255383542:layer:AWS-AppConfig-Extension:328', + arm64: + 'arn:aws:lambda:us-east-1:027255383542:layer:AWS-AppConfig-Extension-Arm64:261', + }, + 'eu-west-1': { + x86_64: + 'arn:aws:lambda:eu-west-1:434848589818:layer:AWS-AppConfig-Extension:305', + arm64: + 'arn:aws:lambda:eu-west-1:434848589818:layer:AWS-AppConfig-Extension-Arm64:243', + }, +} satisfies Record>; + +const getAppConfigAgentLayerArn = (): string => { + const region = process.env.AWS_REGION ?? process.env.CDK_DEFAULT_REGION; + const layerArn = + region && region in appConfigAgentLayerArns + ? appConfigAgentLayerArns[region as keyof typeof appConfigAgentLayerArns][ + getArchitectureKey() + ] + : undefined; + if (!layerArn) { + throw new Error( + `No AppConfig Agent Lambda extension layer ARN known for region '${region}' and architecture '${getArchitectureKey()}'` + ); + } + + return layerArn; +}; + +/** + * This test suite deploys a CDK stack with a Lambda function that has the AWS AppConfig Agent + * Lambda extension layer attached, plus a number of AppConfig configuration profiles. + * The function code uses the `getConfig` function from the `appconfig-agent` module to retrieve + * the configurations from the agent's local HTTP endpoint and returns them as the invocation + * payload. + * + * Once the stack is deployed, the Lambda function is invoked and the returned payload is + * checked against the expected values for each test case. If any of the `getConfig` calls + * unexpectedly throws, the invocation reports a `FunctionError` and the test suite fails. + * + * The configuration profiles created are: + * - Free-form JSON + * - Free-form YAML + * - Free-form plain text base64-encoded string + * - Feature flag + * + * The tests are: + * + * Test 1 + * get a configuration as-is (no transformation - should return a string) + * + * Test 2 + * get a free-form JSON and apply json transformation (should return an object) + * + * Test 3 + * get a free-form base64-encoded plain text and apply binary transformation (should return a decoded string) + * + * Test 4 + * get a feature flag (agent returns the evaluated flag values) and apply json transformation (should return an object) + * + * Test 5 + * get a configuration that does not exist with throwOnMissing (should throw a ParameterNotFoundError) + * + * Test 6 + * get a configuration that does not exist (should return undefined) + */ +describe('Parameters E2E tests, AppConfig Agent', () => { + const testStack = new TestStack({ + stackNameProps: { + stackNamePrefix: RESOURCE_NAME_PREFIX, + testName: 'AppConfigAgent', + }, + }); + + // Location of the lambda function code + const lambdaFunctionCodeFilePath = join( + __dirname, + 'appConfigAgent.test.functionCode.ts' + ); + + const freeFormJsonValue = { + foo: 'bar', + }; + const freeFormYamlValue = `foo: bar +`; + const freeFormPlainTextValue = 'foo'; + const freeFormBase64PlainTextValue = toBase64( + new TextEncoder().encode(freeFormPlainTextValue) + ); + const featureFlagValue = { + version: '1', + flags: { + myFeatureFlag: { + name: 'myFeatureFlag', + }, + }, + values: { + myFeatureFlag: { + enabled: true, + }, + }, + }; + + let functionResult: Record; + + beforeAll(async () => { + // Prepare + const testFunction = new TestNodejsFunction( + testStack, + { + entry: lambdaFunctionCodeFilePath, + layers: [ + LayerVersion.fromLayerVersionArn( + testStack.stack, + 'appConfigAgentLayer', + getAppConfigAgentLayerArn() + ), + ], + }, + { + nameSuffix: 'appConfigAgent', + outputFormat: 'ESM', + } + ); + + const appConfigResource = new TestAppConfigWithProfiles(testStack, { + profiles: [ + { + nameSuffix: 'freeFormJson', + type: 'AWS.Freeform', + content: { + content: JSON.stringify(freeFormJsonValue), + contentType: 'application/json', + }, + }, + { + nameSuffix: 'freeFormYaml', + type: 'AWS.Freeform', + content: { + content: freeFormYamlValue, + contentType: 'application/x-yaml', + }, + }, + { + nameSuffix: 'freeFormB64Plain', + type: 'AWS.Freeform', + content: { + content: freeFormBase64PlainTextValue, + contentType: 'text/plain', + }, + }, + { + nameSuffix: 'featureFlag', + type: 'AWS.AppConfig.FeatureFlags', + content: { + content: JSON.stringify(featureFlagValue), + contentType: 'application/json', + }, + }, + ], + }); + // Grant read permissions to the function + appConfigResource.grantReadData(testFunction); + // Also grant access to the nonexistent profile requested in Test 5, so that + // the agent surfaces AppConfig's 404 instead of an IAM 403 + appConfigResource.grantReadDataForMissingProfile( + testFunction, + 'does-not-exist' + ); + // Add environment variables containing the resource names to the function + appConfigResource.addEnvVariablesToFunction(testFunction); + + // Deploy the stack + await testStack.deploy(); + + // Get the actual function names from the stack outputs + const functionName = testStack.findAndGetStackOutputValue('appConfigAgent'); + + // and invoke the Lambda function + const result = await new LambdaClient({}).send( + new InvokeCommand({ + FunctionName: functionName, + InvocationType: 'RequestResponse', + Payload: fromUtf8(JSON.stringify({})), + }) + ); + const payload = JSON.parse(toUtf8(result.Payload ?? new Uint8Array())); + if (result.FunctionError) { + throw new Error( + `The Lambda function failed unexpectedly: ${JSON.stringify(payload)}` + ); + } + functionResult = payload; + }); + + describe('getConfig usage', () => { + // Test 1 - get a configuration as-is (no transformation - should return a string) + it('retrieves a single configuration as-is', () => { + expect(functionResult.raw).toStrictEqual(freeFormYamlValue); + }); + + // Test 2 - get a free-form JSON and apply json transformation (should return an object) + it('retrieves a free-form JSON configuration with JSON transformation', () => { + expect(functionResult.json).toStrictEqual(freeFormJsonValue); + }); + + // Test 3 - get a free-form base64-encoded plain text and apply binary transformation + // (should return a decoded string) + it('retrieves a base64-encoded plain text configuration with binary transformation', () => { + expect(functionResult.binary).toStrictEqual(freeFormPlainTextValue); + }); + + // Test 4 - get a feature flag and apply json transformation (should return an object + // with the evaluated flag values) + it('retrieves a feature flag configuration with JSON transformation', () => { + expect(functionResult.featureFlag).toStrictEqual(featureFlagValue.values); + }); + + // Test 5 - get a configuration that does not exist with throwOnMissing + // (should throw a ParameterNotFoundError) + it('throws a ParameterNotFoundError when the configuration does not exist and throwOnMissing is set', () => { + expect(functionResult.missingConfigErrorName).toBe( + 'ParameterNotFoundError' + ); + }); + + // Test 6 - get a configuration that does not exist (should return undefined) + it('returns undefined when the configuration does not exist', () => { + expect(functionResult.missingConfigIsUndefined).toBe(true); + }); + }); + + afterAll(async () => { + if (!process.env.DISABLE_TEARDOWN) { + await testStack.destroy(); + } + }); +}); diff --git a/packages/parameters/tests/helpers/resources.ts b/packages/parameters/tests/helpers/resources.ts index 1f2d415750..2317c2a7c6 100644 --- a/packages/parameters/tests/helpers/resources.ts +++ b/packages/parameters/tests/helpers/resources.ts @@ -325,6 +325,37 @@ class TestAppConfigWithProfiles extends Construct { ); } + /** + * Grant access to a configuration profile name that doesn't exist in the application. + * + * IAM evaluates permissions before resource existence, so without this grant a request + * for a missing profile is rejected with `AccessDeniedException` (403) instead of + * surfacing AppConfig's `ResourceNotFoundException` (404). + * + * @param fn The function to grant access to the profile name + * @param profileName The name of the nonexistent configuration profile + */ + public grantReadDataForMissingProfile( + fn: TestNodejsFunction, + profileName: string + ): void { + const appConfigConfigurationArn = Stack.of(fn).formatArn({ + service: 'appconfig', + resource: `application/${this.application.applicationId}/environment/${this.environment.environmentId}/configuration/${profileName}`, + }); + + fn.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'appconfig:StartConfigurationSession', + 'appconfig:GetLatestConfiguration', + ], + resources: [appConfigConfigurationArn], + }) + ); + } + /** * Grant access to all the profiles to a function. * diff --git a/packages/parameters/tests/types/returnTypes.test.ts b/packages/parameters/tests/types/returnTypes.test.ts index 41866c7d8e..a2ae8ded44 100644 --- a/packages/parameters/tests/types/returnTypes.test.ts +++ b/packages/parameters/tests/types/returnTypes.test.ts @@ -16,8 +16,9 @@ import { import { toBase64 } from '@smithy/util-base64'; import { Uint8ArrayBlobAdapter } from '@smithy/util-stream'; import { mockClient } from 'aws-sdk-client-mock'; -import { beforeEach, describe, expectTypeOf, it } from 'vitest'; +import { afterEach, beforeEach, describe, expectTypeOf, it, vi } from 'vitest'; import { getAppConfig } from '../../src/appconfig/index.js'; +import { getConfig } from '../../src/appconfig-agent/index.js'; import { Transform } from '../../src/index.js'; import { getSecret } from '../../src/secrets/index.js'; import { getParameter, getParameters } from '../../src/ssm/index.js'; @@ -27,12 +28,25 @@ describe('Return types', () => { const ssmClient = mockClient(SSMClient); const secretsClient = mockClient(SecretsManagerClient); const encoder = new TextEncoder(); + const stubFetchForAppConfigAgent = (body: string) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(body), + }) + ); + }; beforeEach(() => { appConfigclient.reset(); ssmClient.reset(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('returns a string value when called with transform: `binary` option', async () => { // Prepare const expectedValue = 'my-value'; @@ -296,4 +310,115 @@ describe('Return types', () => { // Assess expectTypeOf(value).toEqualTypeOf(); }); + + it('returns a string value when getConfig is called without a transform', async () => { + // Prepare + stubFetchForAppConfigAgent('my-value'); + + // Act + const value = await getConfig('my-config', { + application: 'my-app', + environment: 'prod', + }); + + // Assess + expectTypeOf(value).toEqualTypeOf(); + }); + + it('returns a JSON value when getConfig is called with transform `json`', async () => { + // Prepare + stubFetchForAppConfigAgent('{"key":"value"}'); + + // Act + const value = await getConfig('my-config', { + application: 'my-app', + environment: 'prod', + transform: 'json', + }); + + // Assess + expectTypeOf(value).toEqualTypeOf(); + }); + + it('returns a string value when getConfig is called with transform `binary`', async () => { + // Prepare + stubFetchForAppConfigAgent(toBase64(encoder.encode('my-value'))); + + // Act + const value = await getConfig('my-config', { + application: 'my-app', + environment: 'prod', + transform: 'binary', + }); + + // Assess + expectTypeOf(value).toEqualTypeOf(); + }); + + it('casts the provided generic type when getConfig is called with a type parameter', async () => { + // Prepare + stubFetchForAppConfigAgent('{"key":"value"}'); + + // Act + const value = await getConfig<{ key: string }>('my-config', { + application: 'my-app', + environment: 'prod', + transform: 'json', + }); + + // Assess + expectTypeOf(value).toEqualTypeOf<{ key: string } | undefined>(); + }); + + it('casts the provided generic type when getConfig is called with a type parameter and no transform', async () => { + // Prepare + stubFetchForAppConfigAgent('my-value'); + + // Act + const value = await getConfig<'on' | 'off'>('my-config', { + application: 'my-app', + environment: 'prod', + }); + + // Assess + expectTypeOf(value).toEqualTypeOf<'on' | 'off' | undefined>(); + }); + + it('narrows the type to exclude undefined when throwOnMissing is set on getConfig', async () => { + // Prepare + stubFetchForAppConfigAgent('{"key":"value"}'); + + // Act + const value = await getConfig('my-config', { + application: 'my-app', + environment: 'prod', + transform: 'json', + throwOnMissing: true, + }); + + // Assess + expectTypeOf(value).toEqualTypeOf(); + }); + + it('rejects invalid options for getConfig at compile time', () => { + // Assess - the closure is never invoked, it only has to typecheck + const _invalidUsages = async () => { + // @ts-expect-error - environment is required + await getConfig('my-config', { application: 'my-app' }); + await getConfig('my-config', { + application: 'my-app', + environment: 'prod', + // @ts-expect-error - `auto` transform is not supported + transform: 'auto', + }); + await getConfig('my-config', { + application: 'my-app', + environment: 'prod', + // @ts-expect-error - transform must be one of the supported literals + transform: 'yaml', + }); + }; + + expectTypeOf(_invalidUsages).toBeFunction(); + }); }); diff --git a/packages/parameters/tests/unit/getConfig.test.ts b/packages/parameters/tests/unit/getConfig.test.ts new file mode 100644 index 0000000000..dff75f4c05 --- /dev/null +++ b/packages/parameters/tests/unit/getConfig.test.ts @@ -0,0 +1,401 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getConfig } from '../../src/appconfig-agent/index.js'; +import { + GetParameterError, + ParameterNotFoundError, + TransformParameterError, +} from '../../src/errors.js'; + +describe('Function: getConfig', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('POWERTOOLS_DEV', 'false'); + vi.stubEnv('AWS_LAMBDA_INITIALIZATION_TYPE', 'on-demand'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('returns undefined in dev mode', async () => { + // Prepare + vi.stubEnv('POWERTOOLS_DEV', 'true'); + + // Act + const result = await getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + }); + + // Assess + expect(result).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns undefined when not running in a Lambda environment', async () => { + // Prepare + vi.stubEnv('AWS_LAMBDA_INITIALIZATION_TYPE', undefined); + + // Act + const result = await getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + }); + + // Assess + expect(result).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('throws a ParameterNotFoundError when not running in a Lambda environment, no local value is set, and throwOnMissing is set', async () => { + // Prepare + vi.stubEnv('AWS_LAMBDA_INITIALIZATION_TYPE', undefined); + + // Act & Assess + await expect( + getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + throwOnMissing: true, + }) + ).rejects.toThrow( + new ParameterNotFoundError('Configuration my-config not found') + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns the local value when not running in a Lambda environment', async () => { + // Prepare + vi.stubEnv('AWS_LAMBDA_INITIALIZATION_TYPE', undefined); + vi.stubEnv('POWERTOOLS_APPCONFIG_AGENT_RETURN_VALUE', 'my-local-value'); + + // Act + const result = await getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + }); + + // Assess + expect(result).toBe('my-local-value'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('applies the transform to the local value when not running in a Lambda environment', async () => { + // Prepare + vi.stubEnv('AWS_LAMBDA_INITIALIZATION_TYPE', undefined); + vi.stubEnv('POWERTOOLS_APPCONFIG_AGENT_RETURN_VALUE', '{"feature": true}'); + + // Act + const result = await getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + transform: 'json', + }); + + // Assess + expect(result).toStrictEqual({ feature: true }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('fetches the configuration from the AppConfig Agent and returns it as a string', async () => { + // Prepare + fetchMock.mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('my-config-value'), + }); + + // Act + const result = await getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + }); + + // Assess + expect(result).toBe('my-config-value'); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:2772/applications/my-app/environments/my-env/configurations/my-config', + expect.objectContaining({ + signal: expect.any(AbortSignal), + }) + ); + }); + + it('calls the AppConfig Agent on the port specified in the environment', async () => { + // Prepare + vi.stubEnv('AWS_APPCONFIG_EXTENSION_HTTP_PORT', '2773'); + fetchMock.mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('my-config-value'), + }); + + // Act + await getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + }); + + // Assess + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:2773/applications/my-app/environments/my-env/configurations/my-config', + expect.anything() + ); + }); + + it('falls back to the service name when the application is not provided', async () => { + // Prepare + vi.stubEnv('POWERTOOLS_SERVICE_NAME', 'my-service'); + fetchMock.mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('my-config-value'), + }); + + // Act + await getConfig('my-config', { + environment: 'my-env', + }); + + // Assess + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:2772/applications/my-service/environments/my-env/configurations/my-config', + expect.anything() + ); + }); + + it('throws when the application is not provided and the service name is not set', async () => { + // Prepare + vi.stubEnv('POWERTOOLS_SERVICE_NAME', undefined); + + // Act & Assess + await expect( + getConfig('my-config', { + environment: 'my-env', + }) + ).rejects.toThrow( + new GetParameterError( + 'Application name is not defined or POWERTOOLS_SERVICE_NAME is not set' + ) + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('URL-encodes the path segments', async () => { + // Prepare + fetchMock.mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('my-config-value'), + }); + + // Act + await getConfig('my config', { + application: 'my app', + environment: 'my/env', + }); + + // Assess + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:2772/applications/my%20app/environments/my%2Fenv/configurations/my%20config', + expect.anything() + ); + }); + + it('applies the json transform to the configuration', async () => { + // Prepare + fetchMock.mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('{"feature": true}'), + }); + + // Act + const result = await getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + transform: 'json', + }); + + // Assess + expect(result).toStrictEqual({ feature: true }); + }); + + it('throws when the json transform fails', async () => { + // Prepare + fetchMock.mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('not-json'), + }); + + // Act & Assess + await expect( + getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + transform: 'json', + }) + ).rejects.toThrow(TransformParameterError); + }); + + it('applies the binary transform to the configuration', async () => { + // Prepare + fetchMock.mockResolvedValue({ + ok: true, + text: vi + .fn() + .mockResolvedValue(Buffer.from('my-value').toString('base64')), + }); + + // Act + const result = await getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + transform: 'binary', + }); + + // Assess + expect(result).toBe('my-value'); + }); + + it('returns undefined when the configuration does not exist', async () => { + // Prepare + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + text: vi + .fn() + .mockResolvedValue( + '{"Message":"Unrecognized or malformed path","ResourceType":"Configuration"}' + ), + }); + + // Act + const result = await getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + }); + + // Assess + expect(result).toBeUndefined(); + }); + + it('throws a ParameterNotFoundError when the configuration does not exist and throwOnMissing is set', async () => { + // Prepare + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + text: vi + .fn() + .mockResolvedValue( + '{"Message":"Unrecognized or malformed path","ResourceType":"Configuration"}' + ), + }); + + // Act & Assess + await expect( + getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + throwOnMissing: true, + }) + ).rejects.toThrow( + new ParameterNotFoundError('Configuration my-config not found') + ); + }); + + it('throws when the AppConfig Agent responds with an error', async () => { + // Prepare + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + text: vi.fn().mockResolvedValue('Internal Server Error'), + }); + + // Act & Assess + await expect( + getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + }) + ).rejects.toThrow( + new GetParameterError( + 'Failed to retrieve configuration from AppConfig Agent: 500 Internal Server Error' + ) + ); + }); + + it('wraps network errors in a GetParameterError', async () => { + // Prepare + const networkError = new Error('fetch failed'); + fetchMock.mockRejectedValue(networkError); + + // Act & Assess + await expect( + getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + }) + ).rejects.toSatisfy( + (error: Error) => + error instanceof GetParameterError && + error.message === 'fetch failed' && + error.cause === networkError + ); + }); + + it.each([ + { case: 'default', timeout: undefined, expected: 3000 }, + { case: 'custom', timeout: 5000, expected: 5000 }, + ])('uses the $case timeout when calling the AppConfig Agent', async ({ + timeout, + expected, + }) => { + // Prepare + const timeoutSpy = vi + .spyOn(AbortSignal, 'timeout') + .mockReturnValue(new AbortController().signal); + fetchMock.mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('my-config-value'), + }); + + // Act + await getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + timeout, + }); + + // Assess + expect(timeoutSpy).toHaveBeenCalledWith(expected); + + timeoutSpy.mockRestore(); + }); + + it('wraps a timed out request in a GetParameterError', async () => { + // Prepare + // Reject when the signal aborts, like the real fetch does; the request itself never resolves + fetchMock.mockImplementation( + (_url: string, { signal }: { signal: AbortSignal }) => + new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }) + ); + + // Act & Assess + await expect( + getConfig('my-config', { + application: 'my-app', + environment: 'my-env', + timeout: 1, + }) + ).rejects.toSatisfy( + (error: Error) => + error instanceof GetParameterError && + (error.cause as Error).name === 'TimeoutError' + ); + }); +}); diff --git a/packages/parameters/typedoc.json b/packages/parameters/typedoc.json index 6f77a07a12..dd5e39844c 100644 --- a/packages/parameters/typedoc.json +++ b/packages/parameters/typedoc.json @@ -3,6 +3,8 @@ "entryPoints": [ "./src/appconfig/index.ts", "./src/types/AppConfigProvider.ts", + "./src/appconfig-agent/index.ts", + "./src/types/AppConfigAgent.ts", "./src/ssm/index.ts", "./src/types/SSMProvider.ts", "./src/dynamodb/index.ts",