diff --git a/.gitignore b/.gitignore index 3fe9874..44dfdf0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,6 @@ env/ # Jupyter .ipynb_checkpoints/ -# VS Code -.vscode/ - # Azure .azure/ @@ -26,7 +23,8 @@ labs-in-progress/ # Exclude sensitive or generated files .env - # Coverage data and reports .coverage tests/python/htmlcov/ + +shared/bicep/modules/**/*.json \ No newline at end of file diff --git a/.vscode/README.md b/.vscode/README.md new file mode 100644 index 0000000..5f7c890 --- /dev/null +++ b/.vscode/README.md @@ -0,0 +1,3 @@ +## Fixing Pylance unresolved import warnings + +Follow [this documentation](https://github.com/microsoft/pylance-release/blob/main/TROUBLESHOOTING.md#unresolved-import-warnings). diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3779d1e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "python.analysis.extraPaths": [ + "${workspaceFolder}/shared/python" + ], + "python.analysis.completeFunctionParens": true, + "python.analysis.autoIndent": true, + "python.analysis.diagnosticSeverityOverrides": { + "reportDuplicateImport": "warning", + "reportUndefinedVariable": "information", + "reportUnusedVariable": "information" + }, + "python.envFile": "${workspaceFolder}/.env", + "plantuml.render": "Local", + "plantuml.exportFormat": "svg", + "terminal.integrated.env.windows": { + "PATH": "${env:PATH}" + }, + "plantuml.java": "C:\\Program Files\\OpenJDK\\jdk-22.0.2\\bin\\java.exe", + "plantuml.diagramsRoot": "diagrams/src", + "plantuml.exportOutDir": "diagrams/out" +} diff --git a/README.md b/README.md index 23bbf39..fd0af6b 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,18 @@ The first time you run a Jupyter notebook, you'll be asked to install the Jupyte Now that infrastructure and sample have been stood up, you can experiment with the policies, make requests against APIM, etc. +### Adding a Sample + +Adding a new sample is relatively straight-forward. + +1. Create a new feature branch for the new sample. +1. Copy the `/samples/_TEMPLATE` folder. +1. Rename the copied folder to a name representative of the sample (e.g. "load-balancing", "authX", etc.) +1. Change the `create.ipynb` and `main.bicep` files. Look for the brackets (`[ ]`) brackets for specific inputs. +1. Add any policy.xml files to the same folder if they are specific to this sample. If they are to be reused, place them into the `/shared/apim-policies` folder instead. +1. Test the sample with all supported infrastructures. +1. Create a pull request for merge. + --- ## Development @@ -89,7 +101,6 @@ The repo uses the bicep linter and has rules defined in `bicepconfig.json`. See We welcome contributions! Please consider forking the repo and creating issues and pull requests to share your samples. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. Thank you! - ### Testing & Code Coverage Python modules in `shared/python` are covered by comprehensive unit tests located in `tests/python`. All tests use [pytest](https://docs.pytest.org/) and leverage modern pytest features, including custom markers for unit and HTTP tests. diff --git a/infrastructure/afd-apim/README.md b/infrastructure/afd-apim/README.md index 6079ca2..547f67c 100644 --- a/infrastructure/afd-apim/README.md +++ b/infrastructure/afd-apim/README.md @@ -1,4 +1,3 @@ - # Front Door & API Management & Container Apps Infrastructure Secure architecture that takes all traffic off the public Internet once Azure Front Door is traversed. This is due to Front Door's use of a private link to Azure API Management. diff --git a/infrastructure/afd-apim/create.ipynb b/infrastructure/afd-apim/create.ipynb index b91315b..46ce5d3 100644 --- a/infrastructure/afd-apim/create.ipynb +++ b/infrastructure/afd-apim/create.ipynb @@ -45,12 +45,11 @@ "# 3) Define the APIs and their operations and policies\n", "\n", "# Policies\n", - "default_policy_xml = utils.policy_xml_replacement(DEFAULT_XML_POLICY_PATH)\n", "hello_world_policy_xml = utils.policy_xml_replacement(HELLO_WORLD_XML_POLICY_PATH)\n", "\n", "# Hello World (Root)\n", "api_hwroot_get = GET_APIOperation('This is a GET for API 1', hello_world_policy_xml)\n", - "api_hwroot = API('hello-world', 'Hello World', '', 'This is the root API for Hello World', default_policy_xml, [api_hwroot_get])\n", + "api_hwroot = API('hello-world', 'Hello World', '', 'This is the root API for Hello World', operations = [api_hwroot_get])\n", "\n", "apis: List[API] = [api_hwroot]\n", "\n", @@ -63,16 +62,16 @@ " aca_backend_pool_policy_xml = utils.policy_xml_replacement(ACA_BACKEND_POOL_XML_POLICY_PATH)\n", "\n", " # Hello World (ACA Backend 1)\n", - " api_hwaca_1_get = GET_APIOperation('This is a GET for Hello World on ACA Backend 1', default_policy_xml)\n", - " api_hwaca_1 = API('hello-world-aca-1', 'Hello World (ACA 1)', '/aca-1', 'This is the ACA API for Backend 1', aca_backend_1_policy_xml, [api_hwaca_1_get])\n", + " api_hwaca_1_get = GET_APIOperation('This is a GET for Hello World on ACA Backend 1')\n", + " api_hwaca_1 = API('hello-world-aca-1', 'Hello World (ACA 1)', '/aca-1', 'This is the ACA API for Backend 1', policyXml = aca_backend_1_policy_xml, operations = [api_hwaca_1_get])\n", "\n", " # Hello World (ACA Backend 2)\n", - " api_hwaca_2_get = GET_APIOperation('This is a GET for Hello World on ACA Backend 2', default_policy_xml)\n", - " api_hwaca_2 = API('hello-world-aca-2', 'Hello World (ACA 2)', '/aca-2', 'This is the ACA API for Backend 2', aca_backend_2_policy_xml, [api_hwaca_2_get])\n", + " api_hwaca_2_get = GET_APIOperation('This is a GET for Hello World on ACA Backend 2')\n", + " api_hwaca_2 = API('hello-world-aca-2', 'Hello World (ACA 2)', '/aca-2', 'This is the ACA API for Backend 2', policyXml = aca_backend_2_policy_xml, operations = [api_hwaca_2_get])\n", "\n", " # Hello World (ACA Backend Pool)\n", - " api_hwaca_pool_get = GET_APIOperation('This is a GET for Hello World on ACA Backend Pool', default_policy_xml)\n", - " api_hwaca_pool = API('hello-world-aca-pool', 'Hello World (ACA Pool)', '/aca-pool', 'This is the ACA API for Backend Pool', aca_backend_pool_policy_xml, [api_hwaca_pool_get])\n", + " api_hwaca_pool_get = GET_APIOperation('This is a GET for Hello World on ACA Backend Pool')\n", + " api_hwaca_pool = API('hello-world-aca-pool', 'Hello World (ACA Pool)', '/aca-pool', 'This is the ACA API for Backend Pool', policyXml = aca_backend_pool_policy_xml, operations = [api_hwaca_pool_get])\n", "\n", " # Add ACA APIs to the existing apis array\n", " apis += [api_hwaca_1, api_hwaca_2, api_hwaca_pool]\n", @@ -111,7 +110,7 @@ "\n", "# 3) Print a deployment summary, if successful; otherwise, exit with an error\n", "if not output.success:\n", - " exit('Deployment failed')\n", + " raise SystemExit('Deployment failed')\n", "\n", "if output.success and output.json_data:\n", " apim_service_id = output.get('apimServiceId', 'APIM Service Id')\n", @@ -219,7 +218,7 @@ "\n", "# 3) Print a single, clear deployment summary if successful\n", "if not output.success:\n", - " exit('Deployment failed')\n", + " raise SystemExit('Deployment failed')\n", " \n", "if output.success and output.json_data:\n", " apim_gateway_url = output.get('apimResourceGatewayURL', 'APIM API Gateway URL')\n", diff --git a/infrastructure/apim-aca/README.md b/infrastructure/apim-aca/README.md index 61a0ef8..862b705 100644 --- a/infrastructure/apim-aca/README.md +++ b/infrastructure/apim-aca/README.md @@ -1,4 +1,3 @@ - # API Management & Container Apps Infrastructure This architecture secures API traffic by routing requests through Azure API Management, which is integrated with Azure Container Apps for backend processing. Telemetry is sent to Azure Monitor for observability. diff --git a/infrastructure/apim-aca/create.ipynb b/infrastructure/apim-aca/create.ipynb index 63149fe..bb8374a 100644 --- a/infrastructure/apim-aca/create.ipynb +++ b/infrastructure/apim-aca/create.ipynb @@ -45,7 +45,6 @@ "# 3) Define the APIs and their operations and policies\n", "\n", "# Policies\n", - "default_policy_xml = utils.policy_xml_replacement(DEFAULT_XML_POLICY_PATH)\n", "hello_world_policy_xml = utils.policy_xml_replacement(HELLO_WORLD_XML_POLICY_PATH)\n", "aca_backend_1_policy_xml = utils.policy_xml_replacement(ACA_BACKEND_1_XML_POLICY_PATH)\n", "aca_backend_2_policy_xml = utils.policy_xml_replacement(ACA_BACKEND_2_XML_POLICY_PATH)\n", @@ -53,19 +52,19 @@ "\n", "# Hello World (Root)\n", "api_hwroot_get = GET_APIOperation('This is a GET for Hello World in the root', hello_world_policy_xml)\n", - "api_hwroot = API('hello-world', 'Hello World', '', 'This is the root API for Hello World', default_policy_xml, [api_hwroot_get])\n", + "api_hwroot = API('hello-world', 'Hello World', '', 'This is the root API for Hello World', operations = [api_hwroot_get])\n", "\n", "# Hello World (ACA Backend 1)\n", - "api_hwaca_1_get = GET_APIOperation('This is a GET for Hello World on ACA Backend 1', default_policy_xml)\n", - "api_hwaca_1 = API('hello-world-aca-1', 'Hello World (ACA 1)', '/aca-1', 'This is the ACA API for Backend 1', aca_backend_1_policy_xml, [api_hwaca_1_get])\n", + "api_hwaca_1_get = GET_APIOperation('This is a GET for Hello World on ACA Backend 1')\n", + "api_hwaca_1 = API('hello-world-aca-1', 'Hello World (ACA 1)', '/aca-1', 'This is the ACA API for Backend 1', policyXml = aca_backend_1_policy_xml, operations = [api_hwaca_1_get])\n", "\n", "# Hello World (ACA Backend 2)\n", - "api_hwaca_2_get = GET_APIOperation('This is a GET for Hello World on ACA Backend 2', default_policy_xml)\n", - "api_hwaca_2 = API('hello-world-aca-2', 'Hello World (ACA 2)', '/aca-2', 'This is the ACA API for Backend 2', aca_backend_2_policy_xml, [api_hwaca_2_get])\n", + "api_hwaca_2_get = GET_APIOperation('This is a GET for Hello World on ACA Backend 2')\n", + "api_hwaca_2 = API('hello-world-aca-2', 'Hello World (ACA 2)', '/aca-2', 'This is the ACA API for Backend 2', policyXml = aca_backend_2_policy_xml, operations = [api_hwaca_2_get])\n", "\n", "# Hello World (ACA Backend Pool)\n", - "api_hwaca_pool_get = GET_APIOperation('This is a GET for Hello World on ACA Backend Pool', default_policy_xml)\n", - "api_hwaca_pool = API('hello-world-aca-pool', 'Hello World (ACA Pool)', '/aca-pool', 'This is the ACA API for Backend Pool', aca_backend_pool_policy_xml, [api_hwaca_pool_get])\n", + "api_hwaca_pool_get = GET_APIOperation('This is a GET for Hello World on ACA Backend Pool')\n", + "api_hwaca_pool = API('hello-world-aca-pool', 'Hello World (ACA Pool)', '/aca-pool', 'This is the ACA API for Backend Pool', policyXml = aca_backend_pool_policy_xml, operations = [api_hwaca_pool_get])\n", "\n", "# APIs Array\n", "apis: List[API] = [api_hwroot, api_hwaca_1, api_hwaca_2, api_hwaca_pool]\n", @@ -101,7 +100,7 @@ "\n", "# 3) Check the deployment outputs\n", "if not output.success:\n", - " exit('Deployment failed')\n", + " raise SystemExit('Deployment failed')\n", "\n", "if output.success and output.json_data:\n", " apim_gateway_url = output.get('apimResourceGatewayURL', 'APIM API Gateway URL')\n", diff --git a/infrastructure/simple-apim/README.md b/infrastructure/simple-apim/README.md index 5d38cca..e21dbd0 100644 --- a/infrastructure/simple-apim/README.md +++ b/infrastructure/simple-apim/README.md @@ -1,4 +1,3 @@ - # Simple API Management Infrastructure This architecture provides a basic API gateway using Azure API Management, suitable for simple scenarios where secure API exposure and basic observability are required. diff --git a/infrastructure/simple-apim/create.ipynb b/infrastructure/simple-apim/create.ipynb index ef5df1f..8ece2b7 100644 --- a/infrastructure/simple-apim/create.ipynb +++ b/infrastructure/simple-apim/create.ipynb @@ -43,12 +43,11 @@ "# 3) Define the APIs and their operations and policies\n", "\n", "# Policies\n", - "default_policy_xml = utils.policy_xml_replacement(DEFAULT_XML_POLICY_PATH)\n", "hello_world_policy_xml = utils.policy_xml_replacement(HELLO_WORLD_XML_POLICY_PATH)\n", "\n", "# Hello World (Root)\n", "api_hwroot_get = GET_APIOperation('This is a GET for API 1', hello_world_policy_xml)\n", - "api_hwroot = API('hello-world', 'Hello World', '', 'This is the root API for Hello World', default_policy_xml, [api_hwroot_get])\n", + "api_hwroot = API('hello-world', 'Hello World', '', 'This is the root API for Hello World', operations = [api_hwroot_get])\n", "\n", "# APIs Array\n", "apis: List[API] = [api_hwroot]\n", @@ -84,7 +83,7 @@ "\n", "# 3) Check the deployment outputs\n", "if not output.success:\n", - " exit('Deployment failed')\n", + " raise SystemExit('Deployment failed')\n", "\n", "if output.success and output.json_data:\n", " apim_gateway_url = output.get('apimResourceGatewayURL', 'APIM API Gateway URL')\n", diff --git a/samples/_TEMPLATE/create.ipynb b/samples/_TEMPLATE/create.ipynb new file mode 100644 index 0000000..891ef75 --- /dev/null +++ b/samples/_TEMPLATE/create.ipynb @@ -0,0 +1,175 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Samples: [TEMPLATE NAME]\n", + "\n", + "[BRIEF SAMPLE DESCRIPTION]\n", + "\n", + "⚙️ **Supported infrastructures**: [Comma-separated names of the supported infrastructures or \"All infrastructures\"]\n", + "\n", + "⌚ **Expected *Run All* runtime (excl. infrastructure prerequisite): ~[NOTEBOOK RUNTIME] minute**\n", + "\n", + "## Objectives\n", + "\n", + "1. [LEARNING / EXPERIMENTATION OBJECTIVE 1]\n", + "1. [LEARNING / EXPERIMENTATION OBJECTIVE 2]\n", + "1. ...\n", + "\n", + "## Configuration\n", + "\n", + "1. Decide which of the [Infrastructure Architectures](../../README.md#infrastructure-architectures) you wish to use.\n", + " 1. If the infrastructure _does not_ yet exist, navigate to the desired [infrastructure](../../infrastructure/) folder and follow its README.md.\n", + " 1. If the infrastructure _does_ exist, adjust the `user-defined parameters` in the _Initialize notebook variables_ below. Please ensure that all parameters match your infrastructure." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Initialize notebook variables\n", + "\n", + "Configures everything that's needed for deployment. \n", + "\n", + "[ADD ANY SPECIAL INSTRUCTIONS]\n", + "\n", + "**Modify entries under _1) User-defined parameters_ and _3) Define the APIs and their operations and policies_**." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import utils\n", + "from apimtypes import *\n", + "\n", + "# 1) User-defined parameters (change these as needed)\n", + "rg_location = 'eastus2'\n", + "index = 1\n", + "deployment = INFRASTRUCTURE.AFD_APIM_PE\n", + "tags = ['TAG1', 'TAG2', '...'] // [ENTER DESCRIPTIVE TAG(S)]\n", + "\n", + "# 2) Service-defined parameters (please do not change these)\n", + "rg_name = utils.get_infra_rg_name(deployment, index)\n", + "\n", + "# 3) Define the APIs and their operations and policies\n", + "\n", + "# API 1\n", + "# api1_get = GET_APIOperation('This is a GET for API 1')\n", + "# api1_post = POST_APIOperation('This is a POST for API 1')\n", + "# api1 = API('API1', 'API 1', '/api1', 'This is API 1', operations = [api1_get, api1_post], tags = tags)\n", + "\n", + "# API n\n", + "# ...\n", + "\n", + "# APIs Array\n", + "# apis: List[API] = [api1, apin]\n", + "\n", + "utils.print_ok('Notebook initialized')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Create deployment using Bicep\n", + "\n", + "Creates the bicep deployment into the previously-specified resource group. A bicep parameters file will be created prior to execution." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import utils\n", + "\n", + "# 1) Define the Bicep parameters with serialized APIs\n", + "bicep_parameters = {\n", + " 'apis': {'value': [api.to_dict() for api in apis]}\n", + "}\n", + "\n", + "# 2) Infrastructure must be in place before samples can be layered on top\n", + "if not utils.does_resource_group_exist(rg_name):\n", + " utils.print_error(f'The specified infrastructure resource group and its resources must exist first. Please check that the user-defined parameters above are correctly referencing an existing infrastructure. If it does not yet exist, run the desired infrastructure in the /infra/ folder first.')\n", + " raise SystemExit(1)\n", + "\n", + "# 3) Run the deployment\n", + "output = utils.create_bicep_deployment_group(rg_name, rg_location, deployment, bicep_parameters)\n", + "\n", + "# 4) Print a deployment summary, if successful; otherwise, exit with an error\n", + "if not output.success:\n", + " raise SystemExit('Deployment failed')\n", + "\n", + "if output.success and output.json_data:\n", + " apim_gateway_url = output.get('apimResourceGatewayURL', 'APIM API Gateway URL')\n", + "\n", + "utils.print_ok('Deployment completed')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Verify API Request Success\n", + "\n", + "Assert that the deployment was successful by making simple calls to APIM. \n", + "\n", + "❗️ If the infrastructure shields APIM and requires a different ingress (e.g. Azure Front Door), the request to the APIM gateway URl will fail by design. Obtain the Front Door endpoint hostname and try that instead." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import utils\n", + "from apimrequests import ApimRequests\n", + "\n", + "[ADD RELEVANT TESTS HERE]\n", + "\n", + "# 1) Issue a direct request to API Management\n", + "# reqsApim = ApimRequests(apim_gateway_url)\n", + "# reqsApim.singleGet('/request-headers', msg = 'Calling Request Headers API via API Management Gateway URL. Response codes 200 and 403 are both valid depending on the infrastructure used.')\n", + "\n", + "# # 2) Issue requests against Front Door.\n", + "# # Check if the infrastructure architecture deployment uses Azure Front Door.\n", + "# utils.print_message('Checking if the infrastructure architecture deployment uses Azure Front Door.', blank_above = True)\n", + "# afd_endpoint_url = utils.get_frontdoor_url(deployment, rg_name)\n", + "\n", + "# if afd_endpoint_url:\n", + "# reqsAfd = ApimRequests(afd_endpoint_url)\n", + "# reqsAfd.singleGet('/request-headers', msg = 'Calling Request Headers API via via Azure Front Door. Expect 200.')\n", + "\n", + "utils.print_ok('All done!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/samples/_TEMPLATE/main.bicep b/samples/_TEMPLATE/main.bicep new file mode 100644 index 0000000..cf066dc --- /dev/null +++ b/samples/_TEMPLATE/main.bicep @@ -0,0 +1,52 @@ +// ------------------ +// PARAMETERS +// ------------------ + +@description('Location to be used for resources. Defaults to the resource group location') +param location string = resourceGroup().location + +@description('The unique suffix to append. Defaults to a unique string based on subscription and resource group IDs.') +param resourceSuffix string = uniqueString(subscription().id, resourceGroup().id) + +param apimName string = 'apim-${resourceSuffix}' +param appInsightsName string = 'appi-${resourceSuffix}' +param apis array = [] + + +// ------------------ +// RESOURCES +// ------------------ + +// https://learn.microsoft.com/azure/templates/microsoft.insights/components +resource appInsightsModule 'Microsoft.Insights/components@2020-02-02' existing = { + name: appInsightsName +} + +var appInsightsId = appInsightsModule.id +var appInsightsInstrumentationKey = appInsightsModule.properties.InstrumentationKey + +// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service +resource apimService 'Microsoft.ApiManagement/service@2024-06-01-preview' existing = { + name: apimName +} + +// APIM APIs +module apisModule '../../shared/bicep/modules/apim/v1/api.bicep' = [for api in apis: if(length(apis) > 0) { + name: '${api.name}-${resourceSuffix}' + params: { + apimName: apimName + appInsightsInstrumentationKey: appInsightsInstrumentationKey + appInsightsId: appInsightsId + api: api + } +}] + +// [ADD RELEVANT BICEP MODULES HERE] + +// ------------------ +// MARK: OUTPUTS +// ------------------ + +output apimServiceId string = apimService.id +output apimServiceName string = apimService.name +output apimResourceGatewayURL string = apimService.properties.gatewayUrl diff --git a/samples/policy-playground/create.ipynb b/samples/general/create.ipynb similarity index 69% rename from samples/policy-playground/create.ipynb rename to samples/general/create.ipynb index b6aa604..f3a3341 100644 --- a/samples/policy-playground/create.ipynb +++ b/samples/general/create.ipynb @@ -4,11 +4,25 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Policy Playground\n", + "# Samples: General\n", "\n", "Sets up a simple APIM instance with a variety of policies to experiment.\n", "\n", - "⌚ **Expected *Run All* runtime (excl. infrastructure prerequisite): ~1 minute**" + "⚙️ **Supported infrastructures**: All infrastructures\n", + "\n", + "⌚ **Expected *Run All* runtime (excl. infrastructure prerequisite): ~1 minute**\n", + "\n", + "## Objectives\n", + "\n", + "1. Experience a variety of policies in any of the infrastructure architectures. You may see several examples from our [APIM policy snippets repo](https://github.com/Azure/api-management-policy-snippets).\n", + "1. Become proficient with how policies operate.\n", + "1. Gain confidence in setting up and configuring policies appropriately.\n", + "\n", + "## Configuration\n", + "\n", + "1. Decide which of the [Infrastructure Architectures](../../README.md#infrastructure-architectures) you wish to use.\n", + " 1. If the infrastructure _does not_ yet exist, navigate to the desired [infrastructure](../../infrastructure/) folder and follow its README.md.\n", + " 1. If the infrastructure _does_ exist, adjust the `user-defined parameters` in the _Initialize notebook variables_ below. Please ensure that all parameters match your infrastructure." ] }, { @@ -17,7 +31,7 @@ "source": [ "### Initialize notebook variables\n", "\n", - "Configures everything that's needed for deployment. **Modify entries under _1) User-defined parameters_**." + "Configures everything that's needed for deployment. **Modify entries under _1) User-defined parameters_ and _3) Define the APIs and their operations and policies_**." ] }, { @@ -32,29 +46,27 @@ "# 1) User-defined parameters (change these as needed)\n", "rg_location = 'eastus2'\n", "index = 1\n", - "deployment = INFRASTRUCTURE.SIMPLE_APIM\n", + "deployment = INFRASTRUCTURE.AFD_APIM_PE\n", + "tags = ['general']\n", "\n", "# 2) Service-defined parameters (please do not change these)\n", "rg_name = utils.get_infra_rg_name(deployment, index)\n", "\n", "# 3) Define the APIs and their operations and policies\n", "\n", - "# Policies\n", - "default_policy_xml = utils.policy_xml_replacement(DEFAULT_XML_POLICY_PATH)\n", - "rh_policy_xml = utils.policy_xml_replacement('../../shared/apim-policies/request-headers.xml')\n", - "\n", "# API 1\n", - "api1_get = GET_APIOperation('This is a GET for API 1', default_policy_xml)\n", - "api1_post = POST_APIOperation('This is a POST for API 1', default_policy_xml)\n", - "api1 = API('API1', 'API 1', '/api1', 'This is API 1', default_policy_xml, [api1_get, api1_post])\n", + "api1_get = GET_APIOperation('This is a GET for API 1')\n", + "api1_post = POST_APIOperation('This is a POST for API 1')\n", + "api1 = API('API1', 'API 1', '/api1', 'This is API 1', operations = [api1_get, api1_post], tags = tags)\n", "\n", "# API 2\n", - "api2_post = POST_APIOperation('This is a POST for API 2', default_policy_xml)\n", - "api2 = API('API2', 'API 2', '/api2', 'This is API 2', default_policy_xml, [api2_post])\n", + "api2_post = POST_APIOperation('This is a POST for API 2')\n", + "api2 = API('API2', 'API 2', '/api2', 'This is API 2', operations = [api2_post], tags = tags)\n", "\n", "# Request Headers\n", + "rh_policy_xml = utils.policy_xml_replacement(REQUEST_HEADERS_XML_POLICY_PATH)\n", "rh_get = GET_APIOperation('Gets the request headers for the current request and returns them. Great for troubleshooting.', rh_policy_xml)\n", - "rh = API('requestheaders', 'Request Headers', '/request-headers', 'API for request headers', default_policy_xml, [rh_get])\n", + "rh = API('requestheaders', 'Request Headers', '/request-headers', 'API for request headers', operations = [rh_get], tags = tags)\n", "\n", "# APIs Array\n", "apis: List[API] = [api1, api2, rh]\n", @@ -92,7 +104,10 @@ "# 3) Run the deployment\n", "output = utils.create_bicep_deployment_group(rg_name, rg_location, deployment, bicep_parameters)\n", "\n", - "# 4) Check the deployment outputs\n", + "# 4) Print a deployment summary, if successful; otherwise, exit with an error\n", + "if not output.success:\n", + " raise SystemExit('Deployment failed')\n", + "\n", "if output.success and output.json_data:\n", " apim_gateway_url = output.get('apimResourceGatewayURL', 'APIM API Gateway URL')\n", "\n", @@ -119,9 +134,11 @@ "import utils\n", "from apimrequests import ApimRequests\n", "\n", + "# 1) Issue a direct request to API Management\n", "reqsApim = ApimRequests(apim_gateway_url)\n", "reqsApim.singleGet('/request-headers', msg = 'Calling Request Headers API via API Management Gateway URL. Response codes 200 and 403 are both valid depending on the infrastructure used.')\n", "\n", + "# 2) Issue requests against Front Door.\n", "# Check if the infrastructure architecture deployment uses Azure Front Door.\n", "utils.print_message('Checking if the infrastructure architecture deployment uses Azure Front Door.', blank_above = True)\n", "afd_endpoint_url = utils.get_frontdoor_url(deployment, rg_name)\n", @@ -132,16 +149,6 @@ "\n", "utils.print_ok('All done!')" ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "\n", - "### Clean up resources\n", - "\n", - "When you're finished experimenting, consider cleaning up the infrastructure, if no longer needed to avoid unnecessary costs. Look for the clean-up notebook (`clean-up.ipynb`) in the infrastructure's respective folder." - ] } ], "metadata": { diff --git a/samples/policy-playground/main.bicep b/samples/general/main.bicep similarity index 100% rename from samples/policy-playground/main.bicep rename to samples/general/main.bicep diff --git a/samples/policy-playground/README.md b/samples/policy-playground/README.md deleted file mode 100644 index 5572375..0000000 --- a/samples/policy-playground/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Policy Playground Samples - -General policy playground to experiment with APIM policies not necessarily as part of a larger theme such as auth, etc. - -## Objectives - -1. Experience a variety of policies in any of the infrastructure architectures. You may see several examples from our [APIM policy snippets repo](https://github.com/Azure/api-management-policy-snippets). -1. Become proficient with how policies operate. -1. Gain confidence in setting up and configuring policies appropriately. - -## Configuration - -1. Decide which of the [Infrastructure Architectures](../../README.md#infrastructure-architectures) you wish to use. - 1. If the infrastructure _does not_ yet exist, navigate to the desired [infrastructure](../../infrastructure/) folder and follow its README.md. - 1. If the infrastructure _does_ exist, adjust the `user-defined parameters` in this lab's Jupyter Notebook's [Initialize notebook variables](./create.ipynb#initialize-notebook-variables) section. Please ensure that all parameters match your infrastructure. - -## Execution - -1. Execute this lab's [Jupyter Notebook](./create.ipynb) step-by-step or via _Run All_. diff --git a/shared/bicep/modules/apim/v1/api.bicep b/shared/bicep/modules/apim/v1/api.bicep index 5b854fd..41619ce 100644 --- a/shared/bicep/modules/apim/v1/api.bicep +++ b/shared/bicep/modules/apim/v1/api.bicep @@ -70,6 +70,23 @@ resource apimApi 'Microsoft.ApiManagement/service/apis@2024-06-01-preview' = { } } +// Create APIM tag resources for each tag in api.tags (array or object) +// Only support array of strings for tags (APIM tags) +var tagList = contains(api, 'tags') && !empty(api.tags) ? api.tags : [] + +resource apimTags 'Microsoft.ApiManagement/service/tags@2024-06-01-preview' = [for tag in tagList: { + name: tag + parent: apimService + properties: { + displayName: tag + } +}] + +resource apimApiTags 'Microsoft.ApiManagement/service/apis/tags@2024-06-01-preview' = [for tag in tagList: { + name: tag + parent: apimApi +}] + // https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service/apis/policies resource apiPolicy 'Microsoft.ApiManagement/service/apis/policies@2024-06-01-preview' = if (!empty(api.policyXml)) { name: 'policy' diff --git a/shared/python/apimtypes.py b/shared/python/apimtypes.py index 6b65415..99bc16a 100644 --- a/shared/python/apimtypes.py +++ b/shared/python/apimtypes.py @@ -15,6 +15,7 @@ SHARED_XML_POLICY_BASE_PATH = '../../shared/apim-policies' DEFAULT_XML_POLICY_PATH = f'{SHARED_XML_POLICY_BASE_PATH}/default.xml' HELLO_WORLD_XML_POLICY_PATH = f'{SHARED_XML_POLICY_BASE_PATH}/hello-world.xml' +REQUEST_HEADERS_XML_POLICY_PATH = f'{SHARED_XML_POLICY_BASE_PATH}/request-headers.xml' ACA_BACKEND_1_XML_POLICY_PATH = f'{SHARED_XML_POLICY_BASE_PATH}/aca-backend-1.xml' ACA_BACKEND_2_XML_POLICY_PATH = f'{SHARED_XML_POLICY_BASE_PATH}/aca-backend-2.xml' ACA_BACKEND_POOL_XML_POLICY_PATH = f'{SHARED_XML_POLICY_BASE_PATH}/aca-backend-pool.xml' @@ -23,6 +24,29 @@ SLEEP_TIME_BETWEEN_REQUESTS_MS = 50 +# ------------------------------ +# PRIVATE METHODS +# ------------------------------ + +# Placing this here privately as putting it into the utils module would constitude a circular import +def _read_policy_xml(policy_xml_filepath: str) -> str: + """ + Read and return the contents of a policy XML file. + + Args: + policy_xml_filepath (str): Path to the policy XML file. + + Returns: + str: Contents of the policy XML file. + """ + + # Read the specified policy XML file + with open(policy_xml_filepath, 'r') as policy_xml_file: + policy_template_xml = policy_xml_file.read() + + return policy_template_xml + + # ------------------------------ # CLASSES # ------------------------------ @@ -92,19 +116,21 @@ class API: description: str policyXml: Optional[str] = None operations: Optional[List['APIOperation']] = None + tags: Optional[List[str]] = None # ------------------------------ # CONSTRUCTOR # ------------------------------ - def __init__(self, name: str, displayName: str, path: str, description: str, policyXml: Optional[str], operations: Optional[List['APIOperation']] = None): + def __init__(self, name: str, displayName: str, path: str, description: str, policyXml: Optional[str] = None, operations: Optional[List['APIOperation']] = None, tags: Optional[List[str]] = None): self.name = name self.displayName = displayName self.path = path self.description = description - self.policyXml = policyXml if policyXml is not None else None + self.policyXml = policyXml if policyXml is not None else _read_policy_xml(DEFAULT_XML_POLICY_PATH) self.operations = operations if operations is not None else [] + self.tags = tags if tags is not None else [] # ------------------------------ @@ -123,6 +149,9 @@ def to_dict(self) -> dict: if self.policyXml is not None: api_dict["policyXml"] = self.policyXml + if self.tags: + api_dict["tags"] = self.tags + return api_dict @@ -144,7 +173,7 @@ class APIOperation: # CONSTRUCTOR # ------------------------------ - def __init__(self, name: str, displayName: str, urlTemplate: str, method: HTTP_VERB, description: str, policyXml: str): + def __init__(self, name: str, displayName: str, urlTemplate: str, method: HTTP_VERB, description: str, policyXml: Optional[str] = None): # Validate that method is a valid HTTP_VERB if not isinstance(method, HTTP_VERB): try: @@ -157,7 +186,7 @@ def __init__(self, name: str, displayName: str, urlTemplate: str, method: HTTP_V self.method = method self.urlTemplate = urlTemplate self.description = description - self.policyXml = policyXml + self.policyXml = policyXml if policyXml is not None else _read_policy_xml(DEFAULT_XML_POLICY_PATH) # ------------------------------ @@ -186,7 +215,7 @@ class GET_APIOperation(APIOperation): # CONSTRUCTOR # ------------------------------ - def __init__(self, description: str, policyXml: str): + def __init__(self, description: str, policyXml: Optional[str] = None): super().__init__('GET', 'GET', '/', HTTP_VERB.GET, description, policyXml) @@ -201,5 +230,5 @@ class POST_APIOperation(APIOperation): # CONSTRUCTOR # ------------------------------ - def __init__(self, description: str, policyXml: str): + def __init__(self, description: str, policyXml: Optional[str] = None): super().__init__('POST', 'POST', '/', HTTP_VERB.POST, description, policyXml) \ No newline at end of file diff --git a/tests/python/test_apimtypes.py b/tests/python/test_apimtypes.py index 33c4a49..9aff7f1 100644 --- a/tests/python/test_apimtypes.py +++ b/tests/python/test_apimtypes.py @@ -38,6 +38,53 @@ def test_api_creation(): assert api.description == EXAMPLE_DESCRIPTION assert api.policyXml == EXAMPLE_POLICY_XML assert api.operations == [] + assert api.tags == [] + +@pytest.mark.unit +def test_api_creation_with_tags(): + """Test creation of API object with tags.""" + tags = ["tag1", "tag2"] + api = apimtypes.API( + name = EXAMPLE_NAME, + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML, + operations = None, + tags = tags + ) + assert api.tags == tags + +@pytest.mark.unit +def test_api_to_dict_includes_tags(): + """Test that to_dict includes tags when present.""" + tags = ["foo", "bar"] + api = apimtypes.API( + name = EXAMPLE_NAME, + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML, + operations = None, + tags = tags + ) + d = api.to_dict() + assert "tags" in d + assert d["tags"] == tags + +@pytest.mark.unit +def test_api_to_dict_omits_tags_when_empty(): + """Test that to_dict omits tags when not set or empty.""" + api = apimtypes.API( + name = EXAMPLE_NAME, + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML, + operations = None + ) + d = api.to_dict() + assert "tags" not in d or d["tags"] == [] @pytest.mark.unit @@ -66,7 +113,8 @@ def test_api_equality(): path = EXAMPLE_PATH, description = EXAMPLE_DESCRIPTION, policyXml = EXAMPLE_POLICY_XML, - operations = None + operations = None, + tags = ["a", "b"] ) api2 = apimtypes.API( name = EXAMPLE_NAME, @@ -74,10 +122,23 @@ def test_api_equality(): path = EXAMPLE_PATH, description = EXAMPLE_DESCRIPTION, policyXml = EXAMPLE_POLICY_XML, - operations = None + operations = None, + tags = ["a", "b"] ) assert api1 == api2 + # Different tags should not be equal + api3 = apimtypes.API( + name = EXAMPLE_NAME, + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML, + operations = None, + tags = ["x"] + ) + assert api1 != api3 + def test_api_inequality(): """ Test inequality for API objects with different attributes. @@ -136,13 +197,13 @@ def test_api_missing_fields(): policyXml = EXAMPLE_POLICY_XML ) - with pytest.raises(TypeError): - apimtypes.API( - name = EXAMPLE_NAME, - displayName = EXAMPLE_DISPLAY_NAME, - path = EXAMPLE_PATH, - description = EXAMPLE_DESCRIPTION - ) + # with pytest.raises(TypeError): + # apimtypes.API( + # name = EXAMPLE_NAME, + # displayName = EXAMPLE_DISPLAY_NAME, + # path = EXAMPLE_PATH, + # description = EXAMPLE_DESCRIPTION + # ) # ------------------------------