Skip to content

Commit fa71585

Browse files
SyntaxColoringcaila-marashaj
authored andcommitted
feat(robot-server): Allow adding multiple labware offsets in a single request (#17436)
1 parent 59031fc commit fa71585

File tree

9 files changed

+345
-82
lines changed

9 files changed

+345
-82
lines changed

api-client/src/runs/createLabwareOffset.ts

+16-8
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,25 @@ import { POST, request } from '../request'
22

33
import type { ResponsePromise } from '../request'
44
import type { HostConfig } from '../types'
5-
import type { LegacyLabwareOffsetCreateData, Run } from './types'
5+
import type { LabwareOffset, LegacyLabwareOffsetCreateData } from './types'
66

77
export function createLabwareOffset(
88
config: HostConfig,
99
runId: string,
1010
data: LegacyLabwareOffsetCreateData
11-
): ResponsePromise<Run> {
12-
return request<Run, { data: LegacyLabwareOffsetCreateData }>(
13-
POST,
14-
`/runs/${runId}/labware_offsets`,
15-
{ data },
16-
config
17-
)
11+
): ResponsePromise<LabwareOffset>
12+
export function createLabwareOffset(
13+
config: HostConfig,
14+
runId: string,
15+
data: LegacyLabwareOffsetCreateData[]
16+
): ResponsePromise<LabwareOffset[]>
17+
export function createLabwareOffset(
18+
config: HostConfig,
19+
runId: string,
20+
data: LegacyLabwareOffsetCreateData | LegacyLabwareOffsetCreateData[]
21+
): ResponsePromise<LabwareOffset | LabwareOffset[]> {
22+
return request<
23+
LabwareOffset | LabwareOffset[],
24+
{ data: LegacyLabwareOffsetCreateData | LegacyLabwareOffsetCreateData[] }
25+
>(POST, `/runs/${runId}/labware_offsets`, { data }, config)
1826
}

react-api-client/src/runs/useCreateLabwareOffsetMutation.ts

+18-15
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { createLabwareOffset } from '@opentrons/api-client'
33
import { useHost } from '../api'
44
import type {
55
HostConfig,
6-
Run,
76
LegacyLabwareOffsetCreateData,
7+
LabwareOffset,
88
} from '@opentrons/api-client'
99
import type { UseMutationResult, UseMutateAsyncFunction } from 'react-query'
1010

@@ -14,12 +14,12 @@ interface CreateLabwareOffsetParams {
1414
}
1515

1616
export type UseCreateLabwareOffsetMutationResult = UseMutationResult<
17-
Run,
17+
LabwareOffset,
1818
unknown,
1919
CreateLabwareOffsetParams
2020
> & {
2121
createLabwareOffset: UseMutateAsyncFunction<
22-
Run,
22+
LabwareOffset,
2323
unknown,
2424
CreateLabwareOffsetParams
2525
>
@@ -29,19 +29,22 @@ export function useCreateLabwareOffsetMutation(): UseCreateLabwareOffsetMutation
2929
const host = useHost()
3030
const queryClient = useQueryClient()
3131

32-
const mutation = useMutation<Run, unknown, CreateLabwareOffsetParams>(
33-
({ runId, data }) =>
34-
createLabwareOffset(host as HostConfig, runId, data)
35-
.then(response => {
36-
queryClient.invalidateQueries([host, 'runs']).catch((e: Error) => {
37-
console.error(`error invalidating runs query: ${e.message}`)
38-
})
39-
return response.data
40-
})
41-
.catch((e: Error) => {
42-
console.error(`error creating labware offsets: ${e.message}`)
43-
throw e
32+
const mutation = useMutation<
33+
LabwareOffset,
34+
unknown,
35+
CreateLabwareOffsetParams
36+
>(({ runId, data }) =>
37+
createLabwareOffset(host as HostConfig, runId, data)
38+
.then(response => {
39+
queryClient.invalidateQueries([host, 'runs']).catch((e: Error) => {
40+
console.error(`error invalidating runs query: ${e.message}`)
4441
})
42+
return response.data
43+
})
44+
.catch((e: Error) => {
45+
console.error(`error creating labware offsets: ${e.message}`)
46+
throw e
47+
})
4548
)
4649

4750
return {

robot-server/robot_server/labware_offsets/router.py

+52-24
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
from opentrons.protocol_engine import ModuleModel
1313

1414
from robot_server.labware_offsets.models import LabwareOffsetNotFound
15-
from robot_server.service.dependencies import get_current_time, get_unique_id
15+
from robot_server.service.dependencies import (
16+
UniqueIDFactory,
17+
get_current_time,
18+
)
1619
from robot_server.service.json_api.request import RequestModel
1720
from robot_server.service.json_api.response import (
1821
MultiBodyMeta,
@@ -42,42 +45,67 @@
4245
@PydanticResponse.wrap_route(
4346
router.post,
4447
path="/labwareOffsets",
45-
summary="Store a labware offset",
48+
summary="Store labware offsets",
4649
description=textwrap.dedent(
4750
"""\
48-
Store a labware offset for later retrieval through `GET /labwareOffsets`.
51+
Store labware offsets for later retrieval through `GET /labwareOffsets`.
4952
5053
On its own, this does not affect robot motion.
51-
To do that, you must add the offset to a run, through the `/runs` endpoints.
54+
To do that, you must add the offsets to a run, through the `/runs` endpoints.
55+
56+
The response body's `data` will either be a single offset or a list of offsets,
57+
depending on whether you provided a single offset or a list in the request body's `data`.
5258
"""
5359
),
5460
status_code=201,
5561
include_in_schema=False, # todo(mm, 2025-01-08): Include for v8.4.0.
5662
)
57-
async def post_labware_offset( # noqa: D103
63+
async def post_labware_offsets( # noqa: D103
5864
store: Annotated[LabwareOffsetStore, fastapi.Depends(get_labware_offset_store)],
59-
new_offset_id: Annotated[str, fastapi.Depends(get_unique_id)],
65+
new_offset_id_factory: Annotated[UniqueIDFactory, fastapi.Depends(UniqueIDFactory)],
6066
new_offset_created_at: Annotated[datetime, fastapi.Depends(get_current_time)],
61-
request_body: Annotated[RequestModel[StoredLabwareOffsetCreate], fastapi.Body()],
62-
) -> PydanticResponse[SimpleBody[StoredLabwareOffset]]:
63-
new_offset = IncomingStoredLabwareOffset(
64-
id=new_offset_id,
65-
createdAt=new_offset_created_at,
66-
definitionUri=request_body.data.definitionUri,
67-
locationSequence=request_body.data.locationSequence,
68-
vector=request_body.data.vector,
67+
request_body: Annotated[
68+
RequestModel[StoredLabwareOffsetCreate | list[StoredLabwareOffsetCreate]],
69+
fastapi.Body(),
70+
],
71+
) -> PydanticResponse[SimpleBody[StoredLabwareOffset | list[StoredLabwareOffset]]]:
72+
new_offsets = [
73+
IncomingStoredLabwareOffset(
74+
id=new_offset_id_factory.get(),
75+
createdAt=new_offset_created_at,
76+
definitionUri=request_body_element.definitionUri,
77+
locationSequence=request_body_element.locationSequence,
78+
vector=request_body_element.vector,
79+
)
80+
for request_body_element in (
81+
request_body.data
82+
if isinstance(request_body.data, list)
83+
else [request_body.data]
84+
)
85+
]
86+
87+
for new_offset in new_offsets:
88+
store.add(new_offset)
89+
90+
stored_offsets = [
91+
StoredLabwareOffset.model_construct(
92+
id=incoming.id,
93+
createdAt=incoming.createdAt,
94+
definitionUri=incoming.definitionUri,
95+
locationSequence=incoming.locationSequence,
96+
vector=incoming.vector,
97+
)
98+
for incoming in new_offsets
99+
]
100+
101+
# Return a list if the client POSTed a list, or an object if the client POSTed an object.
102+
# For some reason, mypy needs to be given the type annotation explicitly.
103+
response_data: StoredLabwareOffset | list[StoredLabwareOffset] = (
104+
stored_offsets if isinstance(request_body.data, list) else stored_offsets[0]
69105
)
70-
store.add(new_offset)
106+
71107
return await PydanticResponse.create(
72-
content=SimpleBody.model_construct(
73-
data=StoredLabwareOffset(
74-
id=new_offset_id,
75-
createdAt=new_offset_created_at,
76-
definitionUri=request_body.data.definitionUri,
77-
locationSequence=request_body.data.locationSequence,
78-
vector=request_body.data.vector,
79-
)
80-
),
108+
content=SimpleBody.model_construct(data=response_data),
81109
status_code=201,
82110
)
83111

robot-server/robot_server/maintenance_runs/router/labware_router.py

+31-7
Original file line numberDiff line numberDiff line change
@@ -36,33 +36,57 @@
3636
"There is no matching `GET /maintenance_runs/{runId}/labware_offsets` endpoint."
3737
" To read the list of labware offsets currently on the run,"
3838
" see the run's `labwareOffsets` field."
39+
"\n\n"
40+
"The response body's `data` will either be a single offset or a list of offsets,"
41+
" depending on whether you provided a single offset or a list in the request body's `data`."
3942
),
4043
status_code=status.HTTP_201_CREATED,
4144
responses={
42-
status.HTTP_201_CREATED: {"model": SimpleBody[LabwareOffset]},
45+
status.HTTP_201_CREATED: {
46+
"model": SimpleBody[LabwareOffset | list[LabwareOffset]]
47+
},
4348
status.HTTP_404_NOT_FOUND: {"model": ErrorBody[RunNotFound]},
4449
status.HTTP_409_CONFLICT: {"model": ErrorBody[RunNotIdle]},
4550
},
4651
)
4752
async def add_labware_offset(
48-
request_body: RequestModel[LabwareOffsetCreate | LegacyLabwareOffsetCreate],
53+
request_body: RequestModel[
54+
LabwareOffsetCreate
55+
| LegacyLabwareOffsetCreate
56+
| list[LabwareOffsetCreate | LegacyLabwareOffsetCreate]
57+
],
4958
run_orchestrator_store: Annotated[
5059
MaintenanceRunOrchestratorStore, Depends(get_maintenance_run_orchestrator_store)
5160
],
5261
run: Annotated[MaintenanceRun, Depends(get_run_data_from_url)],
53-
) -> PydanticResponse[SimpleBody[LabwareOffset]]:
54-
"""Add a labware offset to a maintenance run.
62+
) -> PydanticResponse[SimpleBody[LabwareOffset | list[LabwareOffset]]]:
63+
"""Add labware offsets to a maintenance run.
5564
5665
Args:
5766
request_body: New labware offset request data from request body.
5867
run_orchestrator_store: Engine storage interface.
5968
run: Run response data by ID from URL; ensures 404 if run not found.
6069
"""
61-
added_offset = run_orchestrator_store.add_labware_offset(request_body.data)
62-
log.info(f'Added labware offset "{added_offset.id}"' f' to run "{run.id}".')
70+
offsets_to_add = (
71+
request_body.data
72+
if isinstance(request_body.data, list)
73+
else [request_body.data]
74+
)
75+
76+
added_offsets: list[LabwareOffset] = []
77+
for offset_to_add in offsets_to_add:
78+
added_offset = run_orchestrator_store.add_labware_offset(offset_to_add)
79+
added_offsets.append(added_offset)
80+
log.info(f'Added labware offset "{added_offset.id}" to run "{run.id}".')
81+
82+
# Return a list if the client POSTed a list, or an object if the client POSTed an object.
83+
# For some reason, mypy needs to be given the type annotation explicitly.
84+
response_data: LabwareOffset | list[LabwareOffset] = (
85+
added_offsets if isinstance(request_body.data, list) else added_offsets[0]
86+
)
6387

6488
return await PydanticResponse.create(
65-
content=SimpleBody.model_construct(data=added_offset),
89+
content=SimpleBody.model_construct(data=response_data),
6690
status_code=status.HTTP_201_CREATED,
6791
)
6892

robot-server/robot_server/runs/router/labware_router.py

+33-9
Original file line numberDiff line numberDiff line change
@@ -35,29 +35,38 @@
3535
@PydanticResponse.wrap_route(
3636
labware_router.post,
3737
path="/runs/{runId}/labware_offsets",
38-
summary="Add a labware offset to a run",
38+
summary="Add labware offsets to a run",
3939
description=(
40-
"Add a labware offset to an existing run, returning the created offset."
40+
"Add labware offsets to an existing run, returning the created offsets."
4141
"\n\n"
4242
"There is no matching `GET /runs/{runId}/labware_offsets` endpoint."
4343
" To read the list of labware offsets currently on the run,"
4444
" see the run's `labwareOffsets` field."
45+
"\n\n"
46+
"The response body's `data` will either be a single offset or a list of offsets,"
47+
" depending on whether you provided a single offset or a list in the request body's `data`."
4548
),
4649
status_code=status.HTTP_201_CREATED,
4750
responses={
48-
status.HTTP_201_CREATED: {"model": SimpleBody[LabwareOffset]},
51+
status.HTTP_201_CREATED: {
52+
"model": SimpleBody[LabwareOffset | list[LabwareOffset]]
53+
},
4954
status.HTTP_404_NOT_FOUND: {"model": ErrorBody[RunNotFound]},
5055
status.HTTP_409_CONFLICT: {"model": ErrorBody[Union[RunStopped, RunNotIdle]]},
5156
},
5257
)
5358
async def add_labware_offset(
54-
request_body: RequestModel[LegacyLabwareOffsetCreate | LabwareOffsetCreate],
59+
request_body: RequestModel[
60+
LegacyLabwareOffsetCreate
61+
| LabwareOffsetCreate
62+
| list[LegacyLabwareOffsetCreate | LabwareOffsetCreate]
63+
],
5564
run_orchestrator_store: Annotated[
5665
RunOrchestratorStore, Depends(get_run_orchestrator_store)
5766
],
5867
run: Annotated[Run, Depends(get_run_data_from_url)],
59-
) -> PydanticResponse[SimpleBody[LabwareOffset]]:
60-
"""Add a labware offset to a run.
68+
) -> PydanticResponse[SimpleBody[LabwareOffset | list[LabwareOffset]]]:
69+
"""Add labware offsets to a run.
6170
6271
Args:
6372
request_body: New labware offset request data from request body.
@@ -69,11 +78,26 @@ async def add_labware_offset(
6978
status.HTTP_409_CONFLICT
7079
)
7180

72-
added_offset = run_orchestrator_store.add_labware_offset(request_body.data)
73-
log.info(f'Added labware offset "{added_offset.id}"' f' to run "{run.id}".')
81+
offsets_to_add = (
82+
request_body.data
83+
if isinstance(request_body.data, list)
84+
else [request_body.data]
85+
)
86+
87+
added_offsets: list[LabwareOffset] = []
88+
for offset_to_add in offsets_to_add:
89+
added_offset = run_orchestrator_store.add_labware_offset(offset_to_add)
90+
added_offsets.append(added_offset)
91+
log.info(f'Added labware offset "{added_offset.id}" to run "{run.id}".')
92+
93+
# Return a list if the client POSTed a list, or an object if the client POSTed an object.
94+
# For some reason, mypy needs to be given the type annotation explicitly.
95+
response_data: LabwareOffset | list[LabwareOffset] = (
96+
added_offsets if isinstance(request_body.data, list) else added_offsets[0]
97+
)
7498

7599
return await PydanticResponse.create(
76-
content=SimpleBody.model_construct(data=added_offset),
100+
content=SimpleBody.model_construct(data=response_data),
77101
status_code=status.HTTP_201_CREATED,
78102
)
79103

robot-server/robot_server/service/dependencies.py

+21-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,27 @@ async def get_session_manager(
3535

3636
async def get_unique_id() -> str:
3737
"""Get a unique ID string to use as a resource identifier."""
38-
return str(uuid4())
38+
return UniqueIDFactory().get()
39+
40+
41+
class UniqueIDFactory:
42+
"""
43+
This is equivalent to the `get_unique_id()` free function. Wrapping it in a factory
44+
class makes things easier for FastAPI endpoint functions that need multiple unique
45+
IDs. They can do:
46+
47+
unique_id_factory: UniqueIDFactory = fastapi.Depends(UniqueIDFactory)
48+
49+
And then:
50+
51+
unique_id_1 = await unique_id_factory.get()
52+
unique_id_2 = await unique_id_factory.get()
53+
"""
54+
55+
@staticmethod
56+
def get() -> str:
57+
"""Get a unique ID to use as a resource identifier."""
58+
return str(uuid4())
3959

4060

4161
async def get_current_time() -> datetime:

0 commit comments

Comments
 (0)