diff --git a/src/ssvc/api/README.md b/src/ssvc/api/README.md new file mode 100644 index 00000000..33417d04 --- /dev/null +++ b/src/ssvc/api/README.md @@ -0,0 +1,56 @@ +# SSVC API Readme + +This directory contains source code for the SSVC API. + +## Prerequisites + +- `uv` CLI tool installed. You can install it via pip: + +```shell +pip install uv +``` + +We recommend using `uv` to manage your Python environment and dependencies, +so you don't need to manually create and activate virtual environments or +worry about Python versions. + +## Running a local instance in development mode + +From the project root, run: + +```shell +uv --project=src run uvicorn ssvc.api.main:app --reload --port=7777 +``` + +> [!TIP] +> Adjust the port as needed. + +> [!NOTE] +> We're planning to move our `pyproject.toml` to the top level of the project, +> so in the future you may be able to run this command without the `--project` flag. + +This will start the FastAPI server with auto-reload enabled, allowing you to +see changes immediately. + +## Running a local instance in production mode + +From the project root, run: + +```shell +cd docker +docker-compose up api +``` + +This will start the FastAPI server in a Docker container. + +> [!NOTE] +> Docker and Docker Compose must be installed on your machine to use this method. +> Make sure to adjust the `docker-compose.yml` file if you want to change +> the port or other settings. + +> [!TIP] +> The `api` docker target copies the code into the container at build time. +> If you make changes to the code, you'll need to rebuild the Docker image +> using `docker-compose build api` before restarting the container. Or else +> use `docker-compose up --build api` to build and start in one command. + diff --git a/src/ssvc/api/main.py b/src/ssvc/api/main.py index 9b4f58fc..05a592e7 100644 --- a/src/ssvc/api/main.py +++ b/src/ssvc/api/main.py @@ -3,7 +3,6 @@ API for SSVC """ - # Copyright (c) 2025 Carnegie Mellon University. # NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE # ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. @@ -45,12 +44,12 @@ }, ) -app.include_router(router_v1) +app.include_router(router_v1, prefix="/ssvc/api/v1", tags=["SSVC API v1"]) # root should redirect to docs # at least until we have something better to show -@app.get("/", include_in_schema=False) +@app.get("/", include_in_schema=False, description="Redirect to API docs") async def redirect_root_to_docs(): return RedirectResponse(url="/docs") diff --git a/src/ssvc/api/v1/routers/examples.py b/src/ssvc/api/v1/routers/examples.py new file mode 100644 index 00000000..090ba715 --- /dev/null +++ b/src/ssvc/api/v1/routers/examples.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python +""" +SSVC API v1 Examples Router +""" + +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +from fastapi import APIRouter + +from ssvc.decision_points.base import DecisionPoint, DecisionPointValue +from ssvc.decision_tables.base import DecisionTable +from ssvc.examples import ( + EXAMPLE_DECISION_POINT_1, + EXAMPLE_DECISION_TABLE, + EXAMPLE_MINIMAL_DECISION_POINT_VALUE, + EXAMPLE_SELECTION_1, + EXAMPLE_SELECTION_LIST, +) +from ssvc.selection import ( + MinimalDecisionPointValue, + Reference, + Selection, + SelectionList, +) + +router = APIRouter(prefix="/examples", tags=["Examples"]) + +# GET to retrieve a sample object +# POST to validate an object against the pydantic model + + +# Decision Point Values +@router.get( + "/decision-point-values", + response_model=DecisionPointValue, + response_model_exclude_none=True, + summary="Get a sample Decision Point Value", + description="Retrieve a sample Decision Point Value object.", +) +def get_example_decision_point_value() -> DecisionPointValue: + """ + Retrieve a sample Decision Point Value object. + """ + return EXAMPLE_DECISION_POINT_1.values[0] + + +@router.post( + "/decision-point-values", + response_model=DecisionPointValue, + response_model_exclude_none=True, + summary="Validate a Decision Point Value", + description="Validate a Decision Point Value object against the pydantic model.", +) +def validate_decision_point_value( + decision_point_value: DecisionPointValue, +) -> DecisionPointValue: + """ + Validate a Decision Point Value object against the pydantic model. + """ + return decision_point_value + + +# Decision Points +@router.get( + "/decision-points", + response_model=DecisionPoint, + response_model_exclude_none=True, + summary="Get a sample Decision Point", + description="Retrieve a sample Decision Point object.", +) +def get_example_decision_point() -> DecisionPoint: + """ + Retrieve a sample Decision Point object. + """ + return EXAMPLE_DECISION_POINT_1 + + +@router.post( + "/decision-points", + response_model=DecisionPoint, + response_model_exclude_none=True, + summary="Validate a Decision Point", + description="Validate a Decision Point object against the pydantic model.", +) +def validate_decision_point(decision_point: DecisionPoint) -> DecisionPoint: + """ + Validate a Decision Point object against the pydantic model. + """ + return decision_point + + +# Decision Tables +@router.get( + "/decision-tables", + response_model=DecisionTable, + response_model_exclude_none=True, + summary="Get a sample Decision Table", + description="Retrieve a sample Decision Table object.", +) +def get_example_decision_table() -> DecisionTable: + """ + Retrieve a sample Decision Table object. + """ + return EXAMPLE_DECISION_TABLE + + +@router.post( + "/decision-tables", + response_model=DecisionTable, + response_model_exclude_none=True, + summary="Validate a Decision Table", + description="Validate a Decision Table object against the pydantic model.", +) +def validate_decision_table(decision_table: DecisionTable) -> DecisionTable: + """ + Validate a Decision Table object against the pydantic model. + """ + return decision_table + + +# minimal decision point values +@router.get( + "/decision-point-values-minimal", + response_model=MinimalDecisionPointValue, + response_model_exclude_none=True, + summary="Get a minimal Decision Point Value", + description="Retrieve a minimal Decision Point Value object.", +) +def get_minimal_decision_point_value() -> MinimalDecisionPointValue: + """ + Retrieve a minimal Decision Point Value object. + """ + return EXAMPLE_MINIMAL_DECISION_POINT_VALUE + + +@router.post( + "/decision-point-values-minimal", + response_model=MinimalDecisionPointValue, + response_model_exclude_none=True, + summary="Validate a minimal Decision Point Value", + description="Validate a minimal Decision Point Value object against the pydantic model.", +) +def validate_minimal_decision_point_value( + minimal_decision_point_value: MinimalDecisionPointValue, +) -> MinimalDecisionPointValue: + """ + Validate a minimal Decision Point Value object against the pydantic model. + """ + return minimal_decision_point_value + + +# selection +@router.get( + "/selections", + response_model=Selection, + response_model_exclude_none=True, + summary="Get a sample Selection", + description="Retrieve a sample Selection object.", +) +def get_example_selection() -> Selection: + """ + Retrieve a sample Selection object. + """ + return EXAMPLE_SELECTION_1 + + +@router.post( + "/selections", + response_model=Selection, + response_model_exclude_none=True, + summary="Validate a Selection", + description="Validate a Selection object against the pydantic model.", +) +def validate_selection(selection: Selection) -> Selection: + """ + Validate a Selection object against the pydantic model. + """ + return selection + + +# Selection lists +@router.get( + "/selection-lists", + response_model=SelectionList, + response_model_exclude_none=True, + summary="Get a sample Selection List", + description="Retrieve a sample Selection List object.", +) +def get_example_selection_list() -> SelectionList: + """ + Retrieve a sample Selection List object. + """ + return EXAMPLE_SELECTION_LIST + + +@router.post( + "/selection-lists", + response_model=SelectionList, + response_model_exclude_none=True, + summary="Validate a Selection List", + description="Validate a Selection List object against the pydantic model.", +) +def validate_selection_list(selection_list: SelectionList) -> SelectionList: + """ + Validate a Selection List object against the pydantic model. + """ + return selection_list + + +# references +@router.get( + "/references", + response_model=Reference, + response_model_exclude_none=True, + summary="Get sample References", + description="Retrieve a list of sample Reference URIs.", +) +def get_example_references() -> Reference: + """ + Retrieve a list of sample Reference URIs. + """ + return EXAMPLE_SELECTION_LIST.references[0] + + +@router.post( + "/references", + response_model=Reference, + response_model_exclude_none=True, + summary="Validate a Reference", + description="Validate a Reference object against the pydantic model.", +) +def validate_reference(reference: Reference) -> Reference: + """ + Validate a Reference object against the pydantic model. + """ + return reference diff --git a/src/ssvc/api/v1/routers/v1_router.py b/src/ssvc/api/v1/routers/v1_router.py index 9031079f..52422c05 100644 --- a/src/ssvc/api/v1/routers/v1_router.py +++ b/src/ssvc/api/v1/routers/v1_router.py @@ -26,6 +26,7 @@ decision_point, decision_table, decision_tables, + examples, objects, ) from ssvc.api.v1.routers import ( @@ -36,7 +37,8 @@ versions, ) -router_v1 = APIRouter(prefix="/v1", tags=["v1"]) +router_v1 = APIRouter() +router_v1.include_router(examples.router) router_v1.include_router(decision_point.router) router_v1.include_router(decision_points.router) router_v1.include_router(decision_table.router) diff --git a/src/ssvc/examples.py b/src/ssvc/examples.py new file mode 100644 index 00000000..1f4939a6 --- /dev/null +++ b/src/ssvc/examples.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python +""" +Example SSVC object instances +""" + +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +import datetime + +from ssvc.decision_points.base import DecisionPoint, DecisionPointValue +from ssvc.decision_tables.base import DecisionTable +from ssvc.selection import ( + MinimalDecisionPointValue, + Reference, + Selection, + SelectionList, +) + +EXAMPLE_DECISION_POINT_1 = DecisionPoint( + namespace="example", + key="KEY1", + version="1.0.0", + name="Example Decision Point", + definition="This is a sample decision point for demonstration purposes. Values must be an ordered list.", + values=( + DecisionPointValue( + key="V1", + name="Value One", + definition="Value One definition.", + ), + DecisionPointValue( + key="V2", + name="Value Two", + definition="Value Two definition.", + ), + ), + registered=False, +) + +EXAMPLE_DECISION_POINT_2 = DecisionPoint( + namespace="example", + key="KEY2", + version="1.0.0", + name="Example Decision Point 2", + definition="This is another sample decision point for demonstration purposes. Values must be an ordered list.", + values=( + DecisionPointValue( + key="A", + name="Value A", + definition="Value A definition.", + ), + DecisionPointValue( + key="B", + name="Value B", + definition="Value B definition.", + ), + ), + registered=False, +) +EXAMPLE_OUTCOME_DECISION_POINT = DecisionPoint( + namespace="example", + key="OUTCOME", + version="1.0.0", + name="Example Outcome Decision Point", + definition="This is a sample outcome decision point for demonstration purposes. Values must be an ordered list.", + values=( + DecisionPointValue( + key="O1", + name="Outcome One", + definition="Outcome One definition.", + ), + DecisionPointValue( + key="O2", + name="Outcome Two", + definition="Outcome Two definition.", + ), + DecisionPointValue( + key="O3", + name="Outcome Three", + definition="Outcome Three definition.", + ), + ), + registered=False, +) + +EXAMPLE_DECISION_TABLE = DecisionTable( + namespace="example", + key="DT1", + version="1.0.0", + name="Example Decision Table", + definition="This is a sample decision table for demonstration purposes.", + decision_points={ + dp.id: dp + for dp in [ + EXAMPLE_DECISION_POINT_1, + EXAMPLE_DECISION_POINT_2, + EXAMPLE_OUTCOME_DECISION_POINT, + ] + }, + outcome=EXAMPLE_OUTCOME_DECISION_POINT.id, + registered=False, +) +EXAMPLE_SELECTION_1 = Selection.from_decision_point( + decision_point=EXAMPLE_DECISION_POINT_1 +) +EXAMPLE_SELECTION_1.values = [ + EXAMPLE_SELECTION_1.values[0], +] + + +EXAMPLE_SELECTION_2 = Selection.from_decision_point( + decision_point=EXAMPLE_DECISION_POINT_2 +) +EXAMPLE_SELECTION_LIST = SelectionList( + target_ids=["VU#9999999", "CVE-1900-0001"], + selections=[EXAMPLE_SELECTION_1, EXAMPLE_SELECTION_2], + timestamp=datetime.datetime.now(tz=datetime.timezone.utc), + references=[ + Reference(uri="https://example.com", summary="Example reference"), + ], +) + +EXAMPLE_MINIMAL_DECISION_POINT_VALUE = MinimalDecisionPointValue( + key="KEY_REQUIRED", +) diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 9e9e602c..e2ac2e5f 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -30,6 +30,7 @@ ConfigDict, Field, field_validator, + model_serializer, model_validator, ) @@ -140,6 +141,30 @@ class Reference(BaseModel): uri: AnyUrl summary: str + @model_serializer(mode="wrap") + def remove_falsy_fields(self, handler): + data = handler(self) + return {k: v for k, v in data.items() if v} + + @model_validator(mode="before") + @classmethod + def set_default_summary(cls, data): + """ + Ensure that summary is set to an empty string if not provided. + + Args: + data: The input data dictionary. + + Returns: + The modified data dictionary with summary set to an empty string if it was missing. + + """ + if "summary" not in data: + data["summary"] = "" + elif not data["summary"]: + data["summary"] = "" + return data + # override schema generation to ensure that description is not required def model_json_schema(cls, **kwargs): schema = super().model_json_schema(**kwargs) @@ -238,6 +263,11 @@ class SelectionList(_SchemaVersioned, _Timestamped, BaseModel): ], ) + @model_serializer(mode="wrap") + def remove_falsy_fields(self, handler): + data = handler(self) + return {k: v for k, v in data.items() if v} + @model_validator(mode="before") def set_schema_version(cls, data): if "schemaVersion" not in data: @@ -312,25 +342,6 @@ def model_json_schema(cls, **kwargs): schema = strip_nullable_anyof(schema) return order_schema(schema) - def _post_process(self, data): - """ - Ensures all Selection.values are lists and removes empty array elements. - """ - for x in list(data.keys()): - if not data[x]: - del data[x] - return data - - def model_dump(self, *args, **kwargs): - data = super().model_dump(*args, **kwargs) - return self._post_process(data) - - def model_dump_json(self, *args, **kwargs): - import json - jsontext = super().model_dump_json(*args, **kwargs) - data = self._post_process(json.loads(jsontext)) - return json.dumps(data, **{k: v for k, v in kwargs.items() if k in json.dumps.__code__.co_varnames}) - def main() -> None: diff --git a/src/test/api/routers/test_examples.py b/src/test/api/routers/test_examples.py new file mode 100644 index 00000000..0abec341 --- /dev/null +++ b/src/test/api/routers/test_examples.py @@ -0,0 +1,178 @@ +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 +import datetime +import random +import unittest + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from ssvc.api.v1.routers import examples +from ssvc.decision_points.base import DecisionPoint, DecisionPointValue +from ssvc.decision_tables.base import DecisionTable +from ssvc.decision_tables.example.to_play import TOPLAY_1 +from ssvc.selection import ( + MinimalDecisionPointValue, + Reference, + Selection, + SelectionList, +) + + +class MyTestCase(unittest.TestCase): + def setUp(self): + self.app = FastAPI() + self.app.include_router(examples.router) + self.client = TestClient(self.app) + + # set up a decision table from which we can derive other objects in post-tests + self.dt = TOPLAY_1 + + def tearDown(self): + pass + + def _test_get_example(self, endpoint: str, model: type): + response = self.client.get(endpoint) + self.assertEqual(response.status_code, 200) + data = response.json() + + self.assertIsInstance(data, dict) + + try: + model.model_validate(data) + except Exception as e: + self.fail(f"Validation failed: {e}") + + def _test_post_example(self, endpoint: str, model: type, obj: object): + response = self.client.post(endpoint, json=obj.model_dump(mode="json")) + self.assertEqual( + 200, response.status_code, f"POST to {endpoint} failed: {response}" + ) + data = response.json() + + try: + model.model_validate(data) + except Exception as e: + self.fail(f"Validation failed: {e}") + + def test_get_decision_point_values(self): + self._test_get_example( + "/examples/decision-point-values", DecisionPointValue + ) + + def test_get_decision_points(self): + self._test_get_example("/examples/decision-points", DecisionPoint) + + def test_get_decision_tables(self): + self._test_get_example("/examples/decision-tables", DecisionTable) + + def test_get_minimal_decision_point_values(self): + self._test_get_example( + "/examples/decision-point-values-minimal", + MinimalDecisionPointValue, + ) + + def test_get_selections(self): + self._test_get_example("/examples/selections", Selection) + + def test_get_selection_lists(self): + self._test_get_example("/examples/selection-lists", SelectionList) + + def test_get_references(self): + self._test_get_example("/examples/references", Reference) + + def test_post_decision_points(self): + for dp in self.dt.decision_points.values(): + self._test_post_example( + "/examples/decision-points", DecisionPoint, dp + ) + # TODO test bad data + + def test_post_decision_point_values(self): + for dp in self.dt.decision_points.values(): + for dpv in dp.values: + self._test_post_example( + "/examples/decision-point-values", DecisionPointValue, dpv + ) + # TODO test bad data + + def test_post_decision_tables(self): + self._test_post_example( + "/examples/decision-tables", DecisionTable, self.dt + ) + # TODO test bad data + + def test_post_minimal_decision_point_values(self): + for dp in self.dt.decision_points.values(): + for dpv in dp.values: + mdpv = MinimalDecisionPointValue( + key=dpv.key, + ) + self._test_post_example( + "/examples/decision-point-values-minimal", + MinimalDecisionPointValue, + mdpv, + ) + # TODO test bad data + + def test_post_selections(self): + for dp in self.dt.decision_points.values(): + sel = Selection.from_decision_point(dp) + # randomly sample 1 or more values from the selection + sample_size = random.randint(1, len(sel.values)) + sel.values = random.sample(sel.values, sample_size) + self._test_post_example("/examples/selections", Selection, sel) + + # TODO test bad data + + def test_post_selection_lists(self): + sels = [] + for dp in self.dt.decision_points.values(): + sel = Selection.from_decision_point(dp) + # randomly sample 1 or more values from the selection + sample_size = random.randint(1, len(sel.values)) + sel.values = random.sample(sel.values, sample_size) + sels.append(sel) + + sel_list = SelectionList( + target_ids=["TK-421", "TK-710"], + selections=sels, + timestamp=datetime.datetime.now(), + references=[ + Reference( + uri="https://starwars.fandom.com/wiki/TK-421", + summary="Alongside TK-710, TK-421's first security assignment was to guard the Millennium Falcon in Docking Bay 327 after the Death Star captured it.", + ) + ], + ) + self._test_post_example( + "/examples/selection-lists", SelectionList, sel_list + ) + # TODO test bad data + + def test_post_references(self): + ref = Reference( + uri="http://some/reference", summary="An example reference" + ) + self._test_post_example("/examples/references", Reference, ref) + # TODO test bad data + + +if __name__ == "__main__": + unittest.main() diff --git a/src/test/api/test_main.py b/src/test/api/test_main.py index be81a600..c5d0546b 100644 --- a/src/test/api/test_main.py +++ b/src/test/api/test_main.py @@ -42,6 +42,7 @@ def test_expected_routers(self): "namespaces", "keys", "versions", + "examples", ] routes = [r.path for r in app.routes] for expected in expected_routers: diff --git a/src/test/test_selections.py b/src/test/test_selections.py index b5c532aa..f5d2e5c0 100644 --- a/src/test/test_selections.py +++ b/src/test/test_selections.py @@ -17,10 +17,9 @@ # subject to its own license. # DM24-0278 +import json import unittest from datetime import datetime -from unittest import expectedFailure -import json from ssvc import selection from ssvc.selection import MinimalDecisionPointValue, SelectionList @@ -245,8 +244,6 @@ def test_model_dump_json_excludes_none(self): self.assertIn("selections", data) self.assertNotIn("metadata", data) - - @expectedFailure def test_reference_model_without_summary(self): """Test the Reference model.""" uris = [ @@ -275,6 +272,15 @@ def test_reference_model_without_summary(self): self.assertIn(uri, str(ref.uri)) + # while ref might have an empty string summary, + self.assertTrue(hasattr(ref, "summary")) + self.assertEqual("", ref.summary) + # the json export should not include it + json_data = ref.model_dump_json(exclude_none=True) + data = json.loads(json_data) + self.assertIn("uri", data) + self.assertNotIn("summary", data) + def test_selection_list_validators(self): """Test SelectionList validators.""" # Test schema version is set automatically @@ -366,6 +372,58 @@ def test_selection_list_optional_fields(self): self.assertEqual(len(sel_list.references), 1) self.assertEqual(sel_list.decision_point_resources[0].uri, ref.uri) + def test_missing_lists_are_empty_after_init(self): + # if decision_point_resources is not included, it should still validate. + sel_list_no_dpr = SelectionList( + selections=[self.s1, self.s2], + timestamp=datetime.now(), + ) + for attribute in [ + "decision_point_resources", + "references", + "target_ids", + ]: + self.assertTrue( + hasattr(sel_list_no_dpr, attribute), + f"Attribute {attribute} is missing", + ) + _value = getattr(sel_list_no_dpr, attribute) + self.assertIsInstance(_value, list) + self.assertEqual(0, len(_value)) + + # but they should not appear in the model dump to JSON + dumped = sel_list_no_dpr.model_dump() + self.assertNotIn(attribute, dumped) + + def test_validation_when_empty_lists_provided(self): + sel_list_no_dpr = SelectionList( + selections=[self.s1, self.s2], + timestamp=datetime.now(), + ) + json_data = sel_list_no_dpr.model_dump_json(exclude_none=True) + + data = json.loads(json_data) + + check_attrs = [ + "decision_point_resources", + "references", + "target_ids", + ] + + for attr in check_attrs: + self.assertNotIn(attr, data) + + new_obj = SelectionList.model_validate(data) + + for attr in check_attrs: + self.assertTrue( + hasattr(new_obj, attr), + f"Attribute {attr} is missing after re-validation", + ) + _value = getattr(new_obj, attr) + self.assertIsInstance(_value, list) + self.assertEqual(0, len(_value)) + def test_model_json_schema_customization(self): """Test that JSON schema is properly customized.""" schema = SelectionList.model_json_schema() @@ -409,16 +467,15 @@ def test_selection_list_minimum_selections(self): ) def test_model_dump_removes_required_field(self): - """ Test if a selections is dumped and breaks when items removed """ + """Test if a selections is dumped and breaks when items removed""" s = SelectionList( selections=[self.s1], timestamp=datetime.now(), - ) + ) dumped = s.model_dump() with self.assertRaises(Exception): del dumped['values'] - if __name__ == "__main__": unittest.main()