diff --git a/backend/compact-connect/docs/api-specification/latest-oas30.json b/backend/compact-connect/docs/api-specification/latest-oas30.json index b1996174f..f5e64b53a 100644 --- a/backend/compact-connect/docs/api-specification/latest-oas30.json +++ b/backend/compact-connect/docs/api-specification/latest-oas30.json @@ -10,6 +10,61 @@ } ], "paths": { + "/v1/public/jurisdictions/live": { + "get": { + "summary": "Get live jurisdictions", + "description": "Returns all jurisdictions that are live (enabled for operations) across all compacts or for a specific compact if the optional compact query parameter is provided.", + "parameters": [ + { + "name": "compact", + "in": "query", + "required": false, + "description": "Optional compact abbreviation to filter results. If not provided, returns data for all compacts. If an invalid compact is provided, returns a 400 error.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response - Returns a dictionary with compact abbreviations as keys and arrays of live jurisdiction postal abbreviations as values", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": { + "aslp": ["co", "ne", "wy"], + "octp": ["ak", "ky"] + } + } + } + } + }, + "400": { + "description": "400 response - Invalid compact abbreviation provided", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Invalid request query param: invalid_compact" + } + } + } + } + } + } + } + } + }, "/v1/compacts/{compact}/jurisdictions/{jurisdiction}/licenses": { "post": { "parameters": [ diff --git a/backend/compact-connect/docs/internal/api-specification/latest-oas30.json b/backend/compact-connect/docs/internal/api-specification/latest-oas30.json index 60e772c96..fc0040550 100644 --- a/backend/compact-connect/docs/internal/api-specification/latest-oas30.json +++ b/backend/compact-connect/docs/internal/api-specification/latest-oas30.json @@ -10,6 +10,61 @@ } ], "paths": { + "/v1/public/jurisdictions/live": { + "get": { + "summary": "Get live jurisdictions", + "description": "Returns all jurisdictions that are live (enabled for operations) across all compacts or for a specific compact if the optional compact query parameter is provided.", + "parameters": [ + { + "name": "compact", + "in": "query", + "required": false, + "description": "Optional compact abbreviation to filter results. If not provided, returns data for all compacts. If an invalid compact is provided, returns a 400 error.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response - Returns a dictionary with compact abbreviations as keys and arrays of live jurisdiction postal abbreviations as values", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": { + "aslp": ["co", "ne", "wy"], + "octp": ["ak", "ky"] + } + } + } + } + }, + "400": { + "description": "400 response - Invalid compact abbreviation provided", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Invalid request query param: invalid_compact" + } + } + } + } + } + } + } + } + }, "/v1/compacts/{compact}": { "get": { "parameters": [ diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py index aab3c3558..a31b15097 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py @@ -450,3 +450,31 @@ def update_compact_configured_states(self, compact: str, configured_states: list ':dou': self.config.current_standard_datetime.isoformat(), }, ) + + def get_live_compact_jurisdictions(self, compact: str) -> list[str]: + """ + Get all live (isLive: true) jurisdiction postal abbreviations for a specific compact. + + :param compact: The compact abbreviation + :return: List of jurisdiction postal abbreviations that are live in the compact + """ + logger.info('Getting live jurisdictions for compact', compact=compact) + + try: + compact_config = self.get_compact_configuration(compact) + except CCNotFoundException: + logger.info('Compact configuration not found, returning empty list', compact=compact) + return [] + + # Filter configuredStates for those with isLive: true and extract postal abbreviations + live_jurisdictions = [ + state['postalAbbreviation'] for state in compact_config.configuredStates if state.get('isLive', False) + ] + + logger.info( + 'Retrieved live jurisdictions for compact', + compact=compact, + live_jurisdictions_count=len(live_jurisdictions), + ) + + return live_jurisdictions diff --git a/backend/compact-connect/lambdas/python/compact-configuration/handlers/compact_configuration.py b/backend/compact-connect/lambdas/python/compact-configuration/handlers/compact_configuration.py index 61bda0dc5..0b939f671 100644 --- a/backend/compact-connect/lambdas/python/compact-configuration/handlers/compact_configuration.py +++ b/backend/compact-connect/lambdas/python/compact-configuration/handlers/compact_configuration.py @@ -28,6 +28,8 @@ def compact_configuration_api_handler(event: dict, context: LambdaContext): # n return _get_staff_users_compact_jurisdictions(event, context) if event['httpMethod'] == 'GET' and event['resource'] == '/v1/public/compacts/{compact}/jurisdictions': return _get_public_compact_jurisdictions(event, context) + if event['httpMethod'] == 'GET' and event['resource'] == '/v1/public/jurisdictions/live': + return _get_live_public_compact_jurisdictions(event, context) if event['httpMethod'] == 'GET' and event['resource'] == '/v1/compacts/{compact}': return _get_staff_users_compact_configuration(event, context) if event['httpMethod'] == 'PUT' and event['resource'] == '/v1/compacts/{compact}': @@ -118,6 +120,41 @@ def _get_public_compact_jurisdictions(event: dict, context: LambdaContext): # n return CompactJurisdictionsPublicResponseSchema().load(compact_jurisdictions, many=True) +def _get_live_public_compact_jurisdictions(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Endpoint to get all live jurisdictions, optionally filtered by compact. + + :param event: API Gateway event with optional query parameter 'compact' + :param context: Lambda context + :return: Dictionary with compact abbreviations as keys and lists of live jurisdiction abbreviations as values + """ + query_params = event.get('queryStringParameters') or {} + compact_filter = query_params.get('compact') + + # Determine which compacts to query + compacts_to_query = [] + if compact_filter: + # Validate the compact + if compact_filter.lower() in config.compacts: + compacts_to_query = [compact_filter.lower()] + logger.info('Getting live jurisdictions for specific compact', compact=compact_filter) + else: + logger.info('Invalid compact provided', compact=compact_filter) + raise CCInvalidRequestException(f'Invalid request query param: {compact_filter}') + else: + logger.info('Getting live jurisdictions for all compacts') + compacts_to_query = config.compacts + + # Build result dictionary + result = {} + for compact in compacts_to_query: + live_jurisdictions = config.compact_configuration_client.get_live_compact_jurisdictions(compact=compact) + result[compact] = live_jurisdictions + + logger.info('Returning live jurisdictions', compacts_count=len(result)) + return result + + @authorize_compact_level_only_action(action=CCPermissionsAction.ADMIN) def _get_staff_users_compact_configuration(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument """ diff --git a/backend/compact-connect/lambdas/python/compact-configuration/tests/function/test_compact_configuration.py b/backend/compact-connect/lambdas/python/compact-configuration/tests/function/test_compact_configuration.py index a48c0f3af..2b18e44a9 100644 --- a/backend/compact-connect/lambdas/python/compact-configuration/tests/function/test_compact_configuration.py +++ b/backend/compact-connect/lambdas/python/compact-configuration/tests/function/test_compact_configuration.py @@ -12,6 +12,7 @@ STAFF_USERS_COMPACT_JURISDICTION_ENDPOINT_RESOURCE = '/v1/compacts/{compact}/jurisdictions' PUBLIC_COMPACT_JURISDICTION_ENDPOINT_RESOURCE = '/v1/public/compacts/{compact}/jurisdictions' +LIVE_JURISDICTIONS_ENDPOINT_RESOURCE = '/v1/public/jurisdictions/live' COMPACT_CONFIGURATION_ENDPOINT_RESOURCE = '/v1/compacts/{compact}' JURISDICTION_CONFIGURATION_ENDPOINT_RESOURCE = '/v1/compacts/{compact}/jurisdictions/{jurisdiction}' @@ -191,6 +192,137 @@ def test_get_compact_jurisdictions_returns_list_of_configured_jurisdictions(self sorted_response, ) + def test_get_public_live_compact_jurisdictions_returns_list_of_all_live_jurisdictions(self): + """Test getting list of live jurisdictions across all compacts when no query param provided""" + from handlers.compact_configuration import compact_configuration_api_handler + + # Create compact configurations with some jurisdictions marked as live + # ASLP compact with some live jurisdictions + self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={ + 'compactAbbr': 'aslp', + 'configuredStates': [ + {'postalAbbreviation': 'ky', 'isLive': True}, + {'postalAbbreviation': 'oh', 'isLive': True}, + {'postalAbbreviation': 'ne', 'isLive': False}, + ], + }, + ) + + # OCTP compact with different live jurisdictions + self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={ + 'compactAbbr': 'octp', + 'configuredStates': [ + {'postalAbbreviation': 'ne', 'isLive': True}, + {'postalAbbreviation': 'oh', 'isLive': False}, + ] + }, + ) + + # COUN compact with no live jurisdictions + self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={ + 'compactAbbr': 'coun', + 'configuredStates': [ + {'postalAbbreviation': 'ky', 'isLive': False}, + ], + }, + ) + + # Create event without query params + event = generate_test_event('GET', LIVE_JURISDICTIONS_ENDPOINT_RESOURCE) + event['queryStringParameters'] = None + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + # Should return all compacts with their live jurisdictions + self.assertIn('aslp', response_body) + self.assertIn('octp', response_body) + self.assertIn('coun', response_body) + + # Verify the live jurisdictions for each compact + self.assertCountEqual(['oh', 'ky'], response_body['aslp']) + self.assertCountEqual(['ne'], response_body['octp']) + self.assertCountEqual([], response_body['coun']) + + def test_get_public_live_compact_jurisdictions_returns_list_of_live_jurisdictions_in_compact(self): + """Test getting list of live jurisdictions for compact designated through query param""" + from handlers.compact_configuration import compact_configuration_api_handler + + # Create compact configurations + self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={ + 'compactAbbr': 'aslp', + 'configuredStates': [ + {'postalAbbreviation': 'ky', 'isLive': True}, + {'postalAbbreviation': 'oh', 'isLive': True}, + {'postalAbbreviation': 'ne', 'isLive': False}, + ], + }, + ) + + self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={ + 'compactAbbr': 'octp', + 'configuredStates': [ + {'postalAbbreviation': 'ne', 'isLive': True}, + ], + }, + ) + + # Create event with compact query param + event = generate_test_event('GET', LIVE_JURISDICTIONS_ENDPOINT_RESOURCE) + event['queryStringParameters'] = {'compact': 'aslp'} + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + # Should only return the specified compact + self.assertIn('aslp', response_body) + self.assertNotIn('octp', response_body) + self.assertNotIn('coun', response_body) + + # Verify the live jurisdictions + self.assertCountEqual(['ky', 'oh'], response_body['aslp']) + + def test_get_public_live_compact_jurisdictions_returns_400_if_bad_compact_param(self): + """Test getting list of live jurisdictions returns 400 when invalid query param provided""" + from handlers.compact_configuration import compact_configuration_api_handler + + # Create compact configurations + self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={ + 'compactAbbr': 'aslp', + 'configuredStates': [ + {'postalAbbreviation': 'ky', 'isLive': True}, + ], + }, + ) + + self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={ + 'compactAbbr': 'octp', + 'configuredStates': [ + {'postalAbbreviation': 'oh', 'isLive': True}, + ], + }, + ) + + # Create event with invalid compact query param + event = generate_test_event('GET', LIVE_JURISDICTIONS_ENDPOINT_RESOURCE) + event['queryStringParameters'] = {'compact': 'invalid_compact'} + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + # Verify the error message + self.assertEqual({'message': 'Invalid request query param: invalid_compact'}, response_body) + @mock_aws @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) diff --git a/backend/compact-connect/stacks/api_stack/v1_api/api.py b/backend/compact-connect/stacks/api_stack/v1_api/api.py index 20849d831..bd2f933c0 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/api.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/api.py @@ -107,6 +107,10 @@ def __init__( # POST /v1/public/compacts/{compact}/providers/query # GET /v1/public/compacts/{compact}/providers/{providerId} self.public_compacts_resource = self.public_resource.add_resource('compacts') + # /v1/public/jurisdictions + self.public_jurisdictions_resource = self.public_resource.add_resource('jurisdictions') + # /v1/public/jurisdictions/live + self.live_jurisdictions_resource = self.public_jurisdictions_resource.add_resource('live') self.public_compacts_compact_resource = self.public_compacts_resource.add_resource('{compact}') self.public_compacts_compact_providers_resource = self.public_compacts_compact_resource.add_resource( 'providers' @@ -177,6 +181,7 @@ def __init__( self.compact_configuration_api = CompactConfigurationApi( api=self.api, compact_resource=self.compact_resource, + live_jurisdictions_resource=self.live_jurisdictions_resource, jurisdictions_resource=self.jurisdictions_resource, public_jurisdictions_resource=self.public_compacts_compact_jurisdictions_resource, jurisdiction_resource=self.jurisdiction_resource, diff --git a/backend/compact-connect/stacks/api_stack/v1_api/api_model.py b/backend/compact-connect/stacks/api_stack/v1_api/api_model.py index 1dd14a3bc..d29b30def 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/api_model.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/api_model.py @@ -2360,6 +2360,30 @@ def public_provider_response_model(self) -> Model: ) return self.api._v1_public_provider_response_model + @property + def get_live_jurisdictions_model(self) -> Model: + """Return the get live jurisdictions response model, which should only be created once per API""" + if hasattr(self.api, '_v1_get_live_jurisdictions_response_model'): + return self.api._v1_get_live_jurisdictions_response_model + + # Shape: { "": ["ky", "oh", ...], ... } + # Keys are dynamic compact abbreviations; values are arrays of jurisdiction abbreviations + self.api._v1_get_live_jurisdictions_response_model = self.api.add_model( + 'V1GetLiveJurisdictionsResponseModel', + description='Dictionary keyed by compact abbreviations with arrays of live jurisdiction abbreviations', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema( + type=JsonSchemaType.STRING, + enum=self.api.node.get_context('jurisdictions'), + ), + ), + ), + ) + return self.api._v1_get_live_jurisdictions_response_model + @property def _public_provider_detailed_response_schema(self): """Schema for public provider responses based on ProviderPublicResponseSchema""" diff --git a/backend/compact-connect/stacks/api_stack/v1_api/compact_configuration_api.py b/backend/compact-connect/stacks/api_stack/v1_api/compact_configuration_api.py index e1afb6ac1..a0c46e7b3 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/compact_configuration_api.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/compact_configuration_api.py @@ -21,6 +21,7 @@ def __init__( *, api: CCApi, compact_resource: Resource, + live_jurisdictions_resource: Resource, jurisdictions_resource: Resource, public_jurisdictions_resource: Resource, jurisdiction_resource: Resource, @@ -34,6 +35,8 @@ def __init__( self.api = api # /v1/compacts/{compact} self.staff_users_compact_resource = compact_resource + # /v1/public/jurisdictions/live + self.live_jurisdictions_resource = live_jurisdictions_resource # /v1/compacts/{compact}/jurisdictions self.staff_users_jurisdictions_resource = jurisdictions_resource # /v1/compacts/{compact}/jurisdictions/{jurisdiction} @@ -57,6 +60,10 @@ def __init__( compact_configuration_api_handler=compact_configuration_api_function, ) + self._add_get_live_jurisdictions_endpoint( + compact_configuration_api_handler=compact_configuration_api_function, + ) + self._add_staff_users_get_compact_configuration_endpoint( compact_configuration_api_handler=compact_configuration_api_function, general_read_method_options=general_read_method_options, @@ -123,6 +130,38 @@ def _add_public_get_compact_jurisdictions_endpoint(self, compact_configuration_a ], ) + def _add_get_live_jurisdictions_endpoint(self, compact_configuration_api_handler: PythonFunction): + """Add GET endpoint for /v1/public/jurisdictions/live""" + get_live_compact_jurisdictions_method = self.live_jurisdictions_resource.add_method( + 'GET', + LambdaIntegration(compact_configuration_api_handler), + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_live_jurisdictions_model}, + ), + ], + request_parameters={ + 'method.request.querystring.compact': False, + }, + ) + + # Add suppressions for the public GET endpoint + NagSuppressions.add_resource_suppressions( + get_live_compact_jurisdictions_method, + suppressions=[ + { + 'id': 'AwsSolutions-APIG4', + 'reason': 'This is a public endpoint that intentionally does not require authorization', + }, + { + 'id': 'AwsSolutions-COG4', + 'reason': 'This is a public endpoint that intentionally ' + 'does not use a Cognito user pool authorizer', + }, + ], + ) + def _add_staff_users_get_compact_configuration_endpoint( self, compact_configuration_api_handler: PythonFunction, general_read_method_options: MethodOptions ): diff --git a/backend/compact-connect/tests/app/test_api/test_compact_configuration_api.py b/backend/compact-connect/tests/app/test_api/test_compact_configuration_api.py index 158edf3b9..4881784f6 100644 --- a/backend/compact-connect/tests/app/test_api/test_compact_configuration_api.py +++ b/backend/compact-connect/tests/app/test_api/test_compact_configuration_api.py @@ -150,6 +150,65 @@ def test_synth_generates_get_public_compact_jurisdictions_resource(self): overwrite_snapshot=False, ) + def test_synth_generates_get_live_jurisdictions_resource(self): + """Test that the GET /v1/public/jurisdictions/live + endpoint is properly configured as a public endpoint""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Ensure the /v1/public/jurisdictions resource is created + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'public/' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.public_resource.node.default_child), + }, + 'PathPart': 'jurisdictions', + }, + ) + + # Ensure the /v1/public/jurisdictions/live resource is created + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'public/jurisdictions' resource + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.public_jurisdictions_resource.node.default_child + ), + }, + 'PathPart': 'live', + }, + ) + + # Get the live jurisdictions resource + live_jurisdictions_resource_id = api_stack.get_logical_id( + api_stack.api.v1_api.live_jurisdictions_resource.node.default_child + ) + + # Ensure the GET method is configured with the lambda integration (no authorizer since it's public) + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'GET', + 'ResourceId': {'Ref': live_jurisdictions_resource_id}, + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.compact_configuration_lambdas.compact_configuration_api_handler, + ), + 'MethodResponses': [ + { + 'StatusCode': '200', + }, + ], + }, + ) + def test_synth_generates_get_compact_configuration_endpoint(self): """Test that the GET /v1/compacts/{compact} endpoint is properly configured""" api_stack = self.app.sandbox_backend_stage.api_stack