Repository: PrefectHQ/prefect

Issue:
Build out flow run input CRUD API
Now that the modeling from #11337 is complete we can build out the accompanying CRUD api to store data in the model associated with a key.


Discussion:

Resolving patch:
diff --git a/src/prefect/server/api/flow_runs.py b/src/prefect/server/api/flow_runs.py
--- a/src/prefect/server/api/flow_runs.py
+++ b/src/prefect/server/api/flow_runs.py
@@ -17,7 +17,8 @@
     Response,
     status,
 )
-from prefect._vendor.fastapi.responses import ORJSONResponse
+from prefect._vendor.fastapi.responses import ORJSONResponse, PlainTextResponse
+from sqlalchemy.exc import IntegrityError
 
 import prefect.server.api.dependencies as dependencies
 import prefect.server.models as models
@@ -455,3 +456,82 @@ async def set_flow_run_state(
         response.status_code = status.HTTP_200_OK
 
     return orchestration_result
+
+
+@router.post("/{id}/input", status_code=status.HTTP_201_CREATED)
+async def create_flow_run_input(
+    flow_run_id: UUID = Path(..., description="The flow run id", alias="id"),
+    key: str = Body(..., description="The input key"),
+    value: str = Body(..., description="The value of the input"),
+    db: PrefectDBInterface = Depends(provide_database_interface),
+):
+    """
+    Create a key/value input for a flow run.
+    """
+    async with db.session_context() as session:
+        try:
+            await models.flow_run_input.create_flow_run_input(
+                session=session,
+                flow_run_input=schemas.core.FlowRunInput(
+                    flow_run_id=flow_run_id,
+                    key=key,
+                    value=value,
+                ),
+            )
+            await session.commit()
+
+        except IntegrityError as exc:
+            if "unique constraint" in str(exc).lower():
+                raise HTTPException(
+                    status_code=status.HTTP_409_CONFLICT,
+                    detail="A flow run input with this key already exists.",
+                )
+            else:
+                raise HTTPException(
+                    status_code=status.HTTP_404_NOT_FOUND, detail="Flow run not found"
+                )
+
+
+@router.get("/{id}/input/{key}")
+async def read_flow_run_input(
+    flow_run_id: UUID = Path(..., description="The flow run id", alias="id"),
+    key: str = Path(..., description="The input key", alias="key"),
+    db: PrefectDBInterface = Depends(provide_database_interface),
+) -> PlainTextResponse:
+    """
+    Create a value from a flow run input
+    """
+
+    async with db.session_context() as session:
+        flow_run_input = await models.flow_run_input.read_flow_run_input(
+            session=session, flow_run_id=flow_run_id, key=key
+        )
+
+    if flow_run_input:
+        return PlainTextResponse(flow_run_input.value)
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail="Flow run input not found"
+        )
+
+
+@router.delete("/{id}/input/{key}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_flow_run_input(
+    flow_run_id: UUID = Path(..., description="The flow run id", alias="id"),
+    key: str = Path(..., description="The input key", alias="key"),
+    db: PrefectDBInterface = Depends(provide_database_interface),
+):
+    """
+    Delete a flow run input
+    """
+
+    async with db.session_context() as session:
+        deleted = await models.flow_run_input.delete_flow_run_input(
+            session=session, flow_run_id=flow_run_id, key=key
+        )
+        await session.commit()
+
+        if not deleted:
+            raise HTTPException(
+                status_code=status.HTTP_404_NOT_FOUND, detail="Flow run input not found"
+            )


Test patch:
diff --git a/tests/server/api/test_flow_runs.py b/tests/server/api/test_flow_runs.py
--- a/tests/server/api/test_flow_runs.py
+++ b/tests/server/api/test_flow_runs.py
@@ -3,6 +3,8 @@
 from uuid import UUID, uuid4
 
 import pendulum
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
 
 from prefect._internal.pydantic import HAS_PYDANTIC_V2
 
@@ -1296,3 +1298,121 @@ async def test_supports_filters(
         # lateness is the iteration count of the loop. We're only looking at
         # the last two flow runs in that list so avg(3 + 4) == 3.5
         assert response.content == b"3.5"
+
+
+class TestFlowRunInput:
+    @pytest.fixture
+    async def flow_run_input(self, session: AsyncSession, flow_run):
+        flow_run_input = await models.flow_run_input.create_flow_run_input(
+            session=session,
+            flow_run_input=schemas.core.FlowRunInput(
+                flow_run_id=flow_run.id,
+                key="structured-key-1",
+                value="really important stuff",
+            ),
+        )
+
+        await session.commit()
+
+        return flow_run_input
+
+    async def test_create_flow_run_input(
+        self, flow_run, client: AsyncClient, session: AsyncSession
+    ):
+        response = await client.post(
+            f"/flow_runs/{flow_run.id}/input",
+            json=dict(
+                key="structured-key-1",
+                value="really important stuff",
+            ),
+        )
+
+        assert response.status_code == 201
+
+        flow_run_input = await models.flow_run_input.read_flow_run_input(
+            session=session, flow_run_id=flow_run.id, key="structured-key-1"
+        )
+        assert flow_run_input.flow_run_id == flow_run.id
+        assert flow_run_input.key == "structured-key-1"
+        assert flow_run_input.value == "really important stuff"
+
+    async def test_404_non_existent_flow_run(
+        self, client: AsyncClient, session: AsyncSession
+    ):
+        not_a_flow_run_id = str(uuid4())
+        response = await client.post(
+            f"/flow_runs/{not_a_flow_run_id}/input",
+            json=dict(
+                key="structured-key-1",
+                value="really important stuff",
+            ),
+        )
+
+        assert response.status_code == 404
+
+        flow_run_input = await models.flow_run_input.read_flow_run_input(
+            session=session, flow_run_id=not_a_flow_run_id, key="structured-key-1"
+        )
+        assert flow_run_input is None
+
+    async def test_409_key_conflict(
+        self, flow_run, client: AsyncClient, session: AsyncSession
+    ):
+        response = await client.post(
+            f"/flow_runs/{flow_run.id}/input",
+            json=dict(
+                key="structured-key-1",
+                value="really important stuff",
+            ),
+        )
+
+        assert response.status_code == 201
+
+        # Now try to create the same key again, which should result in a 409
+        response = await client.post(
+            f"/flow_runs/{flow_run.id}/input",
+            json=dict(
+                key="structured-key-1",
+                value="really important stuff",
+            ),
+        )
+
+        assert response.status_code == 409
+
+    async def test_read_flow_run_input(self, client: AsyncClient, flow_run_input):
+        response = await client.get(
+            f"/flow_runs/{flow_run_input.flow_run_id}/input/{flow_run_input.key}",
+        )
+        assert response.status_code == 200
+        assert response.content.decode() == flow_run_input.value
+
+    async def test_404_read_flow_run_input_no_matching_input(
+        self, client: AsyncClient, flow_run
+    ):
+        response = await client.get(
+            f"/flow_runs/{flow_run.id}/input/missing-key",
+        )
+        assert response.status_code == 404
+
+    async def test_delete_flow_run_input(
+        self, client: AsyncClient, session: AsyncSession, flow_run_input
+    ):
+        response = await client.delete(
+            f"/flow_runs/{flow_run_input.flow_run_id}/input/{flow_run_input.key}",
+        )
+        assert response.status_code == 204
+
+        flow_run_input = await models.flow_run_input.read_flow_run_input(
+            session=session,
+            flow_run_id=flow_run_input.flow_run_id,
+            key=flow_run_input.key,
+        )
+        assert flow_run_input is None
+
+    async def test_404_delete_flow_run_input_no_matching_input(
+        self, client: AsyncClient, flow_run_input
+    ):
+        response = await client.delete(
+            f"/flow_runs/{flow_run_input.flow_run_id}/input/missing-key",
+        )
+        assert response.status_code == 404