diff --git a/ansible/collections/ansible_collections/nhsd/apigee/plugins/module_utils/models/apigee/product.py b/ansible/collections/ansible_collections/nhsd/apigee/plugins/module_utils/models/apigee/product.py index f605fe0a4..102b7a875 100644 --- a/ansible/collections/ansible_collections/nhsd/apigee/plugins/module_utils/models/apigee/product.py +++ b/ansible/collections/ansible_collections/nhsd/apigee/plugins/module_utils/models/apigee/product.py @@ -1,27 +1,90 @@ -import typing -import pydantic -import os +import json +from typing import Union, Literal, List, Dict, Any, Type + +from pydantic import ( + BaseModel, + ValidationError, + constr, + conint, + validator, + root_validator, +) +from ansible_collections.nhsd.apigee.plugins.module_utils.models.apigee.rate_limiting_config import ( + RateLimitingConfig, +) -def _literal_name(class_): - # This accesses the 'attribute_name' from - # class class_: - # name: typing.Literal['attribute_name'] - return class_.__fields__['name'].type_.__args__[0] +MANUAL_APPROVAL_EXCEPTIONS = ["canary-api-prod"] + + +class ApigeeProductAttributeRateLimiting(BaseModel): + name: Literal["ratelimiting"] + value: Union[Dict[str, RateLimitingConfig], str] + + @validator("value") + def validate_ratelimiting( + cls, ratelimiting: Union[Dict[str, RateLimitingConfig], str] + ) -> str: + """ + Apigee API requires a string. We decode it as JSON in the + shared flow. -class ApigeeProductAttributeAccess(pydantic.BaseModel): - name: typing.Literal["access"] - value: typing.Literal["public", "private"] + So if pydantic has happily parsed this into a + Dict[str,RateLimitingConfig], then json dump it. + Otherwise, if we've gotten a string (e.g. by calling the + Apigee API) check the schema is valid using the pydantic + models. -class ApigeeProductAttributeRateLimit(pydantic.BaseModel): - name: typing.Literal["ratelimit"] - value: pydantic.constr(regex=r"^[0-9]+(ps|pm)$") + Running strings through a JSON parser will also 'normalize' + the JSON string, so whitespace and key order doesn't matter + for diffs. + """ + error_msg = f"Malformed 'ratelimiting' attribute: {ratelimiting}" + + if isinstance(ratelimiting, str): + # If we have a string, run it through Pydantic by hand. + try: + ratelimiting_dict = json.loads(ratelimiting) + for key, value in ratelimiting_dict.items(): + ratelimiting_dict[key] = RateLimitingConfig(**value) + ratelimiting = ratelimiting_dict + except (ValidationError, json.JSONDecodeError): + raise ValueError(error_msg) + + # Apigee enforces these must be strings, so do a nicely sorted + # JSON dump. + ratelimiting_dict = {} + for proxy_name, config in ratelimiting.items(): + ratelimiting_dict[proxy_name] = config.dict() + ratelimiting = json.dumps(ratelimiting_dict, sort_keys=True) + return ratelimiting + + +class ApigeeProductAttributeAccess(BaseModel): + name: Literal["access"] + value: Literal["public", "private"] + + +class ApigeeProductAttributeRateLimit(BaseModel): + name: Literal["ratelimit"] + value: constr(regex=r"^[0-9]+(ps|pm)$") + + +def _literal_name(class_): + # This accesses the 'attribute_name' from + # class class_: + # name: Literal['attribute_name'] + return class_.__fields__["name"].type_.__args__[0] # This ensures that a generic ApigeeProductAttribute can't be -# constructed from a more specific one that fails valiation. +# constructed from a more specific one that fails valiation. Sadly +# the pydantic error message is a mess, e.g. if you pass in +# 'ratelimiting' with invalid JSON, the error messages will tell you +# you failed validation for all our customized ApigeeProductAttribute +# types. PRODUCT_ATTRIBUTE_REGEX = ( "^(?!(" + "|".join( @@ -29,61 +92,75 @@ class ApigeeProductAttributeRateLimit(pydantic.BaseModel): for c in [ ApigeeProductAttributeAccess, ApigeeProductAttributeRateLimit, + ApigeeProductAttributeRateLimiting, ] ) + ")$)" ) -class ApigeeProductAttribute(pydantic.BaseModel): - name: pydantic.constr(regex=PRODUCT_ATTRIBUTE_REGEX) +class ApigeeProductAttribute(BaseModel): + name: constr(regex=PRODUCT_ATTRIBUTE_REGEX) value: str -class ApigeeProduct(pydantic.BaseModel): +def _count_cls(items: List[Any], cls: Type): + return sum(isinstance(item, cls) for item in items) + + +class ApigeeProduct(BaseModel): name: str - approvalType: typing.Literal["auto", "manual"] - attributes: typing.List[ - typing.Union[ - ApigeeProductAttributeAccess, - ApigeeProductAttributeRateLimit, - ApigeeProductAttribute, - ], - ] + approvalType: Literal["auto", "manual"] + attributes: List[ + Union[ + ApigeeProductAttributeAccess, + ApigeeProductAttributeRateLimit, + ApigeeProductAttributeRateLimiting, + ApigeeProductAttribute, + ], + ] description: str displayName: str - environments: typing.List[str] - proxies: typing.List[str] - quota: str - quotaInterval: str - quotaTimeUnit: typing.Literal["minute", "hour"] - scopes: typing.List[str] - - @pydantic.root_validator + environments: List[str] + proxies: List[str] + quota: constr(regex=r"[1-9][0-9]*") + quotaInterval: constr(regex=r"[1-9][0-9]*") + quotaTimeUnit: Literal["minute", "hour"] + scopes: List[str] + + @root_validator def override_approval_type_for_prod(cls, values): - manual_approval_exceptions = ["canary-api-prod"] - if "prod" in values["environments"]: - if values["approvalType"] == "auto" and not values["name"] in manual_approval_exceptions: - values["approvalType"] = "manual" + name = values["name"] + environments = values["environments"] + if "prod" in environments and name not in MANUAL_APPROVAL_EXCEPTIONS: + values["approvalType"] = "manual" return values - @pydantic.validator("environments", "scopes", "proxies") + @validator("environments", "scopes", "proxies") def sorted(cls, v): return sorted(v) - @pydantic.validator("attributes") + @validator("attributes") def validate_attributes(cls, attributes, values): attributes = sorted(attributes, key=lambda a: a.name) - for class_ in [ - ApigeeProductAttributeAccess, - ApigeeProductAttributeRateLimit, - ]: - attrs = [a for a in attributes if isinstance(a, class_)] - if len(attrs) != 1: + class_min_max = [ + (ApigeeProductAttributeAccess, 1, 1), + (ApigeeProductAttributeRateLimit, 1, 1), + (ApigeeProductAttributeRateLimiting, 0, 1), + ] + + for _class, _min, _max in class_min_max: + count = _count_cls(attributes, _class) + if count < _min or count > _max: + if _min == _max: + count_msg = f"exactly {_min}" + else: + count_msg = f"between {_min} and {_max}" raise AssertionError( - f"Product {values['name']} must contain exactly 1 " - + f"attribute with name: '{_literal_name(class_)}', " - + f"found {len(attrs)}" + f"Product {values['name']} must contain {count_msg} " + + f"'{_literal_name(_class)}' attributes , " + + f"your product has {count}." ) + return attributes diff --git a/ansible/collections/ansible_collections/nhsd/apigee/plugins/module_utils/models/apigee/rate_limiting_config.py b/ansible/collections/ansible_collections/nhsd/apigee/plugins/module_utils/models/apigee/rate_limiting_config.py new file mode 100644 index 000000000..e6899bc9f --- /dev/null +++ b/ansible/collections/ansible_collections/nhsd/apigee/plugins/module_utils/models/apigee/rate_limiting_config.py @@ -0,0 +1,46 @@ +""" +Pydantic class for the rateliming config JSON, attached to products +and apps to control the ApplyRateLimiting shared flow. +""" +from typing import Literal + +from pydantic import BaseModel, conint, constr, Extra + + +class ExcludeNoneModel(BaseModel): + + """ + Providing default values for ratelimiting here would mean that + changing defaults required a redeploy for all proxies. + + Therefore we set None as the default value on all + RateLimitingConfig attributes, and *do not* export them as JSON. + + The platform defaults are used to fill in the missing values + inside the ApplyRateLimiting shared flow. This pattern us to + update the defaults for everyone by just by updating the shared + flow. + """ + def dict(self, **kwargs): + kwargs["exclude_none"] = True + return super().dict(**kwargs) + + class Config: + extra=Extra.forbid + + +class QuotaConfig(ExcludeNoneModel): + enabled: bool = None + interval: conint(gt=0) = None + limit: conint(gt=0) = None + timeunit: Literal["minute", "hour"] = None + + +class SpikeArrestConfig(ExcludeNoneModel): + enabled: bool = None + ratelimit: constr(regex=r"^[1-9][0-9]*(ps|pm)$") = None + + +class RateLimitingConfig(ExcludeNoneModel): + quota: QuotaConfig = None + spikeArrest: SpikeArrestConfig = None diff --git a/ansible/collections/ansible_collections/nhsd/apigee/plugins/module_utils/models/manifest/meta.py b/ansible/collections/ansible_collections/nhsd/apigee/plugins/module_utils/models/manifest/meta.py index 56da99911..6fb56fd4d 100644 --- a/ansible/collections/ansible_collections/nhsd/apigee/plugins/module_utils/models/manifest/meta.py +++ b/ansible/collections/ansible_collections/nhsd/apigee/plugins/module_utils/models/manifest/meta.py @@ -4,7 +4,7 @@ from ansible_collections.nhsd.apigee.plugins.module_utils.paas import api_registry -SCHEMA_VERSION = "1.1.1" +SCHEMA_VERSION = "1.1.2" _REGISTRY_DATA = {} diff --git a/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/conftest.py b/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/conftest.py index c6dfe51e6..9faffb587 100644 --- a/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/conftest.py +++ b/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/conftest.py @@ -27,7 +27,6 @@ def invalid_guid(): def mock_api_registry(monkeypatch): def _mock_api_registry_get(name: str): if name == CANARY_API["name"]: - print("HELLO") return CANARY_API else: raise ValueError(f"No API named {name} found.") diff --git a/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/schema_versions/v1.1.2.json b/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/schema_versions/v1.1.2.json new file mode 100644 index 000000000..949546b89 --- /dev/null +++ b/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/schema_versions/v1.1.2.json @@ -0,0 +1,501 @@ +{ + "title": "Manifest", + "type": "object", + "properties": { + "apigee": { + "$ref": "#/definitions/ManifestApigee" + }, + "meta": { + "$ref": "#/definitions/ManifestMeta" + } + }, + "required": [ + "apigee", + "meta" + ], + "definitions": { + "ApigeeProductAttributeAccess": { + "title": "ApigeeProductAttributeAccess", + "type": "object", + "properties": { + "name": { + "title": "Name", + "const": "access", + "type": "string" + }, + "value": { + "title": "Value", + "anyOf": [ + { + "const": "public", + "type": "string" + }, + { + "const": "private", + "type": "string" + } + ] + } + }, + "required": [ + "name", + "value" + ] + }, + "ApigeeProductAttributeRateLimit": { + "title": "ApigeeProductAttributeRateLimit", + "type": "object", + "properties": { + "name": { + "title": "Name", + "const": "ratelimit", + "type": "string" + }, + "value": { + "title": "Value", + "pattern": "^[0-9]+(ps|pm)$", + "type": "string" + } + }, + "required": [ + "name", + "value" + ] + }, + "QuotaConfig": { + "title": "QuotaConfig", + "description": "Providing default values for ratelimiting here would mean that\nchanging defaults required a redeploy for all proxies.\n\nTherefore we set None as the default value on all\nRateLimitingConfig attributes, and *do not* export them as JSON.\n\nThe platform defaults are used to fill in the missing values\ninside the ApplyRateLimiting shared flow. This pattern us to\nupdate the defaults for everyone by just by updating the shared\nflow.", + "type": "object", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "interval": { + "title": "Interval", + "exclusiveMinimum": 0, + "type": "integer" + }, + "limit": { + "title": "Limit", + "exclusiveMinimum": 0, + "type": "integer" + }, + "timeunit": { + "title": "Timeunit", + "anyOf": [ + { + "const": "minute", + "type": "string" + }, + { + "const": "hour", + "type": "string" + } + ] + } + }, + "additionalProperties": false + }, + "SpikeArrestConfig": { + "title": "SpikeArrestConfig", + "description": "Providing default values for ratelimiting here would mean that\nchanging defaults required a redeploy for all proxies.\n\nTherefore we set None as the default value on all\nRateLimitingConfig attributes, and *do not* export them as JSON.\n\nThe platform defaults are used to fill in the missing values\ninside the ApplyRateLimiting shared flow. This pattern us to\nupdate the defaults for everyone by just by updating the shared\nflow.", + "type": "object", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "ratelimit": { + "title": "Ratelimit", + "pattern": "^[1-9][0-9]*(ps|pm)$", + "type": "string" + } + }, + "additionalProperties": false + }, + "RateLimitingConfig": { + "title": "RateLimitingConfig", + "description": "Providing default values for ratelimiting here would mean that\nchanging defaults required a redeploy for all proxies.\n\nTherefore we set None as the default value on all\nRateLimitingConfig attributes, and *do not* export them as JSON.\n\nThe platform defaults are used to fill in the missing values\ninside the ApplyRateLimiting shared flow. This pattern us to\nupdate the defaults for everyone by just by updating the shared\nflow.", + "type": "object", + "properties": { + "quota": { + "$ref": "#/definitions/QuotaConfig" + }, + "spikeArrest": { + "$ref": "#/definitions/SpikeArrestConfig" + } + }, + "additionalProperties": false + }, + "ApigeeProductAttributeRateLimiting": { + "title": "ApigeeProductAttributeRateLimiting", + "type": "object", + "properties": { + "name": { + "title": "Name", + "const": "ratelimiting", + "type": "string" + }, + "value": { + "title": "Value", + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/RateLimitingConfig" + } + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "name", + "value" + ] + }, + "ApigeeProductAttribute": { + "title": "ApigeeProductAttribute", + "type": "object", + "properties": { + "name": { + "title": "Name", + "pattern": "^(?!(access|ratelimit|ratelimiting)$)", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "name", + "value" + ] + }, + "ApigeeProduct": { + "title": "ApigeeProduct", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "approvalType": { + "title": "Approvaltype", + "anyOf": [ + { + "const": "auto", + "type": "string" + }, + { + "const": "manual", + "type": "string" + } + ] + }, + "attributes": { + "title": "Attributes", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/ApigeeProductAttributeAccess" + }, + { + "$ref": "#/definitions/ApigeeProductAttributeRateLimit" + }, + { + "$ref": "#/definitions/ApigeeProductAttributeRateLimiting" + }, + { + "$ref": "#/definitions/ApigeeProductAttribute" + } + ] + } + }, + "description": { + "title": "Description", + "type": "string" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "environments": { + "title": "Environments", + "type": "array", + "items": { + "type": "string" + } + }, + "proxies": { + "title": "Proxies", + "type": "array", + "items": { + "type": "string" + } + }, + "quota": { + "title": "Quota", + "pattern": "[1-9][0-9]*", + "type": "string" + }, + "quotaInterval": { + "title": "Quotainterval", + "pattern": "[1-9][0-9]*", + "type": "string" + }, + "quotaTimeUnit": { + "title": "Quotatimeunit", + "anyOf": [ + { + "const": "minute", + "type": "string" + }, + { + "const": "hour", + "type": "string" + } + ] + }, + "scopes": { + "title": "Scopes", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "approvalType", + "attributes", + "description", + "displayName", + "environments", + "proxies", + "quota", + "quotaInterval", + "quotaTimeUnit", + "scopes" + ] + }, + "ApigeeSpec": { + "title": "ApigeeSpec", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "format": "file-path", + "type": "string" + }, + "content": { + "title": "Content", + "type": "object" + } + }, + "required": [ + "name", + "path" + ] + }, + "ApigeeApidoc": { + "title": "ApigeeApidoc", + "type": "object", + "properties": { + "edgeAPIProductName": { + "title": "Edgeapiproductname", + "type": "string" + }, + "anonAllowed": { + "title": "Anonallowed", + "type": "boolean" + }, + "description": { + "title": "Description", + "type": "string" + }, + "requireCallbackUrl": { + "title": "Requirecallbackurl", + "type": "boolean" + }, + "title": { + "title": "Title", + "type": "string" + }, + "visibility": { + "title": "Visibility", + "type": "boolean" + }, + "specId": { + "title": "Specid", + "default": "", + "type": "string" + }, + "specContent": { + "title": "Speccontent", + "default": "", + "type": "string" + } + }, + "required": [ + "edgeAPIProductName", + "anonAllowed", + "description", + "requireCallbackUrl", + "title", + "visibility" + ] + }, + "ManifestApigeeEnvironment": { + "title": "ManifestApigeeEnvironment", + "type": "object", + "properties": { + "name": { + "title": "Name", + "anyOf": [ + { + "const": "internal-dev", + "type": "string" + }, + { + "const": "internal-dev-sandbox", + "type": "string" + }, + { + "const": "internal-qa", + "type": "string" + }, + { + "const": "internal-qa-sandbox", + "type": "string" + }, + { + "const": "ref", + "type": "string" + }, + { + "const": "dev", + "type": "string" + }, + { + "const": "sandbox", + "type": "string" + }, + { + "const": "int", + "type": "string" + }, + { + "const": "prod", + "type": "string" + } + ] + }, + "products": { + "title": "Products", + "type": "array", + "items": { + "$ref": "#/definitions/ApigeeProduct" + } + }, + "specs": { + "title": "Specs", + "type": "array", + "items": { + "$ref": "#/definitions/ApigeeSpec" + } + }, + "api_catalog": { + "title": "Api Catalog", + "type": "array", + "items": { + "$ref": "#/definitions/ApigeeApidoc" + } + } + }, + "required": [ + "name", + "products", + "specs", + "api_catalog" + ] + }, + "ManifestApigee": { + "title": "ManifestApigee", + "type": "object", + "properties": { + "environments": { + "title": "Environments", + "type": "array", + "items": { + "$ref": "#/definitions/ManifestApigeeEnvironment" + } + } + }, + "required": [ + "environments" + ] + }, + "ManifestMetaApi": { + "title": "ManifestMetaApi", + "type": "object", + "properties": { + "name": { + "title": "Name", + "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$", + "type": "string" + }, + "id": { + "title": "Id", + "description": "This field is deprecated, use guid instead.", + "type": "string", + "format": "uuid4" + }, + "guid": { + "title": "Guid", + "type": "string", + "format": "uuid4" + }, + "spec_guids": { + "title": "Spec Guids", + "type": "array", + "items": { + "type": "string", + "format": "uuid4" + }, + "uniqueItems": true + } + }, + "required": [ + "name" + ] + }, + "ManifestMeta": { + "title": "ManifestMeta", + "type": "object", + "properties": { + "schema_version": { + "title": "Schema Version", + "pattern": "[1-9][0-9]*(\\.[0-9]+){0,2}", + "type": "string" + }, + "api": { + "$ref": "#/definitions/ManifestMetaApi" + } + }, + "required": [ + "schema_version", + "api" + ] + } + } +} \ No newline at end of file diff --git a/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/test_product.py b/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/test_product.py index c83044364..17ad73389 100644 --- a/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/test_product.py +++ b/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/test_product.py @@ -1,3 +1,5 @@ +import json + import pytest from ansible_collections.nhsd.apigee.plugins.module_utils.models.apigee.product import ( @@ -5,45 +7,52 @@ ) -@pytest.mark.parametrize( - "env,initial_approvalType,final_approvalType", - [ - ("prod", "auto", "manual"), - ("prod", "manual", "manual"), - ("int", "auto", "auto"), - ("int", "manual", "manual"), - ], -) -def test_prod_cannot_have_auto_approvalType_on_products( - env, initial_approvalType, final_approvalType -): - raw_product = { - "name": "test-service", - "approvalType": initial_approvalType, +def _make_product_dict(name, environment="internal-dev", approval_type="auto"): + return { + "name": f"{name}-{environment}", + "approvalType": approval_type, "attributes": [ {"name": "access", "value": "public"}, {"name": "ratelimit", "value": "300pm"}, ], "description": "testing our validators", "displayName": "Test Product", - "environments": [env], - "proxies": [f"identity-service-{env}"], + "environments": [environment], + "proxies": [f"{name}-{environment}", f"identity-service-{environment}"], "scopes": [ - "urn:nhsd:apim:app:level3:test-service", - "urn:nhsd:apim:user-nhs-login:P9:test-service", + f"urn:nhsd:apim:app:level3:{name}", + f"urn:nhsd:apim:user-nhs-login:P9:{name}", ], "quota": "300", "quotaInterval": "1", "quotaTimeUnit": "minute", } - product = ApigeeProduct(**raw_product) + + +@pytest.mark.parametrize( + "env,initial_approvalType,final_approvalType", + [ + ("prod", "auto", "manual"), + ("prod", "manual", "manual"), + ("int", "auto", "auto"), + ("int", "manual", "manual"), + ], +) +def test_prod_cannot_have_auto_approvalType_on_products( + env, initial_approvalType, final_approvalType +): + product_dict = _make_product_dict( + "test-service", environment=env, approval_type=initial_approvalType + ) + product = ApigeeProduct(**product_dict) assert product.approvalType == final_approvalType + @pytest.mark.parametrize( "name,env,initial_approvalType,final_approvalType", [ - ("canary-api-prod", "prod", "auto", "auto"), - ("canary-api-prod", "prod", "manual", "manual"), + ("canary-api", "prod", "auto", "auto"), + ("canary-api", "prod", "manual", "manual"), ("non-exception-product", "prod", "auto", "manual"), ("non-exception-product", "prod", "manual", "manual"), ], @@ -51,24 +60,35 @@ def test_prod_cannot_have_auto_approvalType_on_products( def test_manual_approval_exception_list_on_prod( name, env, initial_approvalType, final_approvalType ): - raw_product = { - "name": name, - "approvalType": initial_approvalType, - "attributes": [ - {"name": "access", "value": "public"}, - {"name": "ratelimit", "value": "300pm"}, - ], - "description": "testing our validators", - "displayName": "Test Product", - "environments": [env], - "proxies": [f"identity-service-{env}"], - "scopes": [ - "urn:nhsd:apim:app:level3:test-service", - "urn:nhsd:apim:user-nhs-login:P9:test-service", - ], - "quota": "300", - "quotaInterval": "1", - "quotaTimeUnit": "minute", - } - product = ApigeeProduct(**raw_product) + product_dict = _make_product_dict( + name, environment=env, approval_type=initial_approvalType + ) + product = ApigeeProduct(**product_dict) assert product.approvalType == final_approvalType + + +def test_ratelimiting_product_attribute_initialized_with_dict_or_string(): + product_dict = _make_product_dict("test-service") + + ratelimiting_dict = { + "quota": {"enabled": True, "interval": 1, "timeunit": "minute", "limit": 300}, + "spikeArrest": {"ratelimit": "30000pm"}, # 5000 tps + } + attr_dict = {product_dict["name"]: ratelimiting_dict} + + # Init with dict-like + product_dict["attributes"].append( + {"name": "ratelimiting", "value": attr_dict} + ) + product1 = ApigeeProduct(**product_dict) + + # Assert ratelimiting attribute gets serialized to a string + assert type(product1.attributes[-1].value) == str + + # Init with attribute values already a string + attr_str = json.dumps(attr_dict) + product_dict["attributes"][-1]["value"] = attr_str + product2 = ApigeeProduct(**product_dict) + + # Should be the same + assert product1.attributes[-1].value == product2.attributes[-1].value diff --git a/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/test_ratelimiting_config.py b/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/test_ratelimiting_config.py new file mode 100644 index 000000000..abac7f98a --- /dev/null +++ b/ansible/collections/ansible_collections/nhsd/apigee/tests/unit/plugins/module_utils/models/manifest/test_ratelimiting_config.py @@ -0,0 +1,20 @@ +import pytest + +from ansible_collections.nhsd.apigee.plugins.module_utils.models.apigee.rate_limiting_config import ( + RateLimitingConfig, +) + +def test_missing_values_are_not_exported(): + input_dict = {"quota": {"enabled": True}, "spikeArrest": {"enabled": False}} + + ratelimiting = RateLimitingConfig(**input_dict) + + # For it's brief Class-typed existance these things are None. + assert ratelimiting.quota.interval is None + assert ratelimiting.quota.limit is None + assert ratelimiting.quota.timeunit is None + assert ratelimiting.spikeArrest.ratelimit is None + + output_dict = ratelimiting.dict() + + assert output_dict == input_dict diff --git a/ansible/roles/remove-old-pr-proxies/tasks/remove-api-product.yml b/ansible/roles/remove-old-pr-proxies/tasks/remove-api-product.yml index 3a669c259..a5c10f92a 100644 --- a/ansible/roles/remove-old-pr-proxies/tasks/remove-api-product.yml +++ b/ansible/roles/remove-old-pr-proxies/tasks/remove-api-product.yml @@ -1,7 +1,7 @@ - name: "get product {{ product_slug }}" uri: - url: "{{ products_uri }}/{{ product_slug }}" + url: "{{ products_uri }}/{{ product_slug | urlencode }}" headers: Authorization: "Bearer {{ APIGEE_ACCESS_TOKEN }}" return_content: yes @@ -13,7 +13,7 @@ - name: "update product {{ product_slug }}" uri: - url: "{{ products_uri }}/{{ product_slug }}" + url: "{{ products_uri }}/{{ product_slug | urlencode }}" headers: Authorization: "Bearer {{ APIGEE_ACCESS_TOKEN }}" body_format: json diff --git a/azure/common/deploy-stage.yml b/azure/common/deploy-stage.yml index 720ac4158..189547dce 100644 --- a/azure/common/deploy-stage.yml +++ b/azure/common/deploy-stage.yml @@ -222,6 +222,24 @@ stages: fi displayName: Override SERVICE_BASE_PATH + - bash: | + set -euo pipefail + + INFO=$(curl https://api-registry.ptl.api.platform.nhs.uk:9000/api/${{ parameters.service_name }}) + SHORT_NAME=$(echo $INFO | jq -r .short_name) + GUID=$(echo $INFO | jq -r .guid) + + echo "##[debug]Fetched info from API Registry" + echo "##[debug]short_name: $SHORT_NAME" + echo "##[debug]guid: $GUID" + + if [[ $SHORT_NAME != "${{ parameters.short_service_name }}" ]]; then + echo "##[warning]Short name provided to pipeline (${{ parameters.short_service_name }}) does not match name in registry ($SHORT_NAME)" + echo "##vso[task.logissue type=warning]Short name provided to pipeline (${{ parameters.short_service_name }}) does not match name in registry ($SHORT_NAME)" + fi + displayName: Check supplied names against API registry + continueOnError: true + - template: '../templates/deploy-service.yml' parameters: service_name: ${{ parameters.service_name }}