fix(api): type interview answer submissions

Replace the loose interview answer request payload with a discriminated OpenAPI union so generated clients enforce the wire contract. Surface structured HTTP error details in the web client and update browser, CLI, and server answer submission paths to use the typed variants.
This commit is contained in:
Bryan Helmkamp 2026-05-08 09:18:35 -07:00
parent 025f41dddd
commit b0dfd6b3b4
No known key found for this signature in database
22 changed files with 543 additions and 99 deletions

View file

@ -7,6 +7,7 @@ import {
} from "@qltysh/fabro-api-client";
import { InterviewDock, displayLabel } from "./interview-dock";
import { generatedAxios } from "../lib/api-client";
function render(node: React.ReactNode): TestRenderer.ReactTestRenderer {
let tree: TestRenderer.ReactTestRenderer | undefined;
@ -81,6 +82,41 @@ describe("InterviewDock", () => {
expect(buttons.No).toBeDefined();
});
test("yes/no question submits typed yes and no answers", async () => {
const submitted: unknown[] = [];
const originalAdapter = generatedAxios.defaults.adapter;
generatedAxios.defaults.adapter = async (config) => {
submitted.push(JSON.parse(String(config.data)));
return {
data: undefined,
status: 204,
statusText: "No Content",
headers: {},
config,
};
};
try {
const tree = render(
<InterviewDock runId="run-1" questions={[makeQuestion()]} />,
);
const buttons = buttonsByText(tree);
await act(async () => {
buttons.Yes.props.onClick();
await Promise.resolve();
});
await act(async () => {
buttons.No.props.onClick();
await Promise.resolve();
});
expect(submitted).toEqual([{ kind: "yes" }, { kind: "no" }]);
} finally {
generatedAxios.defaults.adapter = originalAdapter;
}
});
test("multiple choice question renders option buttons with stripped accelerator prefixes", () => {
const question = makeQuestion({
question_type: QuestionType.MULTIPLE_CHOICE,

View file

@ -22,6 +22,7 @@ import {
useSubmitInterviewAnswer,
type SubmitInterviewAnswerArg,
} from "../lib/mutations";
import { ApiError } from "../lib/api-client";
import { ErrorMessage } from "./ui";
const PRIMARY_BUTTON =
@ -33,6 +34,8 @@ const CHOICE_BUTTON =
const CHOICE_BUTTON_SELECTED =
"inline-flex items-center justify-center gap-1.5 rounded-lg bg-teal-500/15 px-3.5 py-2 text-sm font-medium text-fg outline-1 -outline-offset-1 outline-teal-500/60 transition-colors hover:bg-teal-500/20 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500";
type SubmitInterviewAnswer = SubmitInterviewAnswerArg["answer"];
export interface InterviewDockProps {
runId: string;
questions: ApiQuestion[];
@ -52,15 +55,13 @@ export function InterviewDock({ runId, questions }: InterviewDockProps) {
}, [question?.id, submitMutation.reset]);
const submit = useCallback(
async (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => {
async (answer: SubmitInterviewAnswer) => {
if (!question) return;
setError(null);
try {
await submitMutation.trigger({ ...arg, questionId: question.id });
await submitMutation.trigger({ questionId: question.id, answer });
} catch (caught) {
setError(
caught instanceof Error ? caught.message : "Couldn't submit your answer.",
);
setError(interviewSubmitErrorMessage(caught));
}
},
[question, submitMutation],
@ -172,7 +173,7 @@ function QuestionBody({
}: {
question: ApiQuestion;
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
}) {
switch (question.question_type) {
case QuestionType.YES_NO:
@ -216,14 +217,14 @@ function YesNoBody({
onSubmit,
}: {
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={submitting}
onClick={() => void onSubmit({ value: "no" })}
onClick={() => void onSubmit({ kind: "no" })}
className={CHOICE_BUTTON}
>
No
@ -231,7 +232,7 @@ function YesNoBody({
<button
type="button"
disabled={submitting}
onClick={() => void onSubmit({ value: "yes" })}
onClick={() => void onSubmit({ kind: "yes" })}
className={PRIMARY_BUTTON}
>
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
@ -246,14 +247,14 @@ function ConfirmationBody({
onSubmit,
}: {
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={submitting}
onClick={() => void onSubmit({ value: "yes" })}
onClick={() => void onSubmit({ kind: "yes" })}
className={PRIMARY_BUTTON}
>
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
@ -272,7 +273,7 @@ function ChoiceBody({
options: ApiQuestionOption[];
allowFreeform: boolean;
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
}) {
return (
<div className="space-y-4">
@ -283,7 +284,7 @@ function ChoiceBody({
key={option.key}
type="button"
disabled={submitting}
onClick={() => void onSubmit({ selected_option_key: option.key })}
onClick={() => void onSubmit({ kind: "selected", option_key: option.key })}
className={CHOICE_BUTTON}
>
{displayLabel(option.label)}
@ -315,7 +316,7 @@ function MultiSelectBody({
}: {
options: ApiQuestionOption[];
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
@ -357,7 +358,7 @@ function MultiSelectBody({
<button
type="button"
disabled={submitting || selectedKeys.length === 0}
onClick={() => void onSubmit({ selected_option_keys: selectedKeys })}
onClick={() => void onSubmit({ kind: "multi_selected", option_keys: selectedKeys })}
className={PRIMARY_BUTTON}
>
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
@ -377,7 +378,7 @@ function FreeformBody({
divider = false,
}: {
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
placeholder: string;
submitLabel: string;
autoFocus?: boolean;
@ -394,7 +395,7 @@ function FreeformBody({
event.preventDefault();
const trimmed = value.trim();
if (!trimmed || submitting) return;
await onSubmit({ value: trimmed });
await onSubmit({ kind: "text", text: trimmed });
setValue("");
}
@ -484,3 +485,12 @@ export function displayLabel(label: string): string {
.trim();
return stripped || trimmed;
}
function interviewSubmitErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.requestId
? `${error.message} Request ID: ${error.requestId}`
: error.message;
}
return error instanceof Error ? error.message : "Couldn't submit your answer.";
}

View file

@ -45,7 +45,12 @@ describe("generated Axios adapter", () => {
test("normalizes generated client failures into ApiError", async () => {
const body = {
errors: [{ status: "500", title: "Internal", request_id: "body-req" }],
errors: [{
status: "500",
title: "Internal",
detail: "Database is unavailable.",
request_id: "body-req",
}],
};
try {
@ -64,7 +69,7 @@ describe("generated Axios adapter", () => {
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({
status: 500,
message: "Internal Server Error",
message: "Database is unavailable.",
requestId: "header-req",
body,
});

View file

@ -176,12 +176,23 @@ function apiErrorFromAxios(error: unknown): ApiError | null {
const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(response.data);
return new ApiError({
status: response.status,
message: response.statusText || `HTTP ${response.status}`,
message: extractErrorDetail(response.data) ?? (response.statusText || `HTTP ${response.status}`),
requestId,
body: response.data ?? null,
});
}
function extractErrorDetail(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const errors = (body as Record<string, unknown>).errors;
if (!Array.isArray(errors) || errors.length === 0) return null;
const first = errors[0];
if (!first || typeof first !== "object") return null;
const detail = (first as Record<string, unknown>).detail;
return typeof detail === "string" && detail.length > 0 ? detail : null;
}
function redirectToLogin(error: ApiError, options: ApiCallOptions) {
if (error.status !== 401 || options.redirectOnUnauthorized === false) return;
if (typeof window !== "undefined") {

View file

@ -101,7 +101,10 @@ function useLifecycleMutation(
);
}
export type SubmitInterviewAnswerArg = SubmitAnswerRequest & { questionId: string };
export type SubmitInterviewAnswerArg = {
questionId: string;
answer: SubmitAnswerRequest;
};
export function useSubmitInterviewAnswer(runId: string | undefined) {
const { mutate } = useSWRConfig();
@ -109,8 +112,9 @@ export function useSubmitInterviewAnswer(runId: string | undefined) {
runId ? `interview-answer:${runId}` : null,
async (_key: string, { arg }: { arg: SubmitInterviewAnswerArg }) => {
if (!runId) throw new Error("runId is required");
const { questionId, ...body } = arg;
await apiData(() => humanInTheLoopApi.submitRunAnswer(runId, questionId, body));
await apiData(() =>
humanInTheLoopApi.submitRunAnswer(runId, arg.questionId, arg.answer),
);
},
{
onSuccess: () => {

View file

@ -5177,25 +5177,90 @@ components:
SubmitAnswerRequest:
description: >
Request body for submitting an answer to a pending question.
At least one of `value`, `selected_option_key`, or `selected_option_keys` must be provided.
Request body for submitting an answer to a pending question. The
`kind` discriminator determines which answer shape is submitted.
oneOf:
- $ref: "#/components/schemas/SubmitAnswerYesRequest"
- $ref: "#/components/schemas/SubmitAnswerNoRequest"
- $ref: "#/components/schemas/SubmitAnswerSelectedRequest"
- $ref: "#/components/schemas/SubmitAnswerMultiSelectedRequest"
- $ref: "#/components/schemas/SubmitAnswerTextRequest"
discriminator:
propertyName: kind
mapping:
"yes": "#/components/schemas/SubmitAnswerYesRequest"
"no": "#/components/schemas/SubmitAnswerNoRequest"
selected: "#/components/schemas/SubmitAnswerSelectedRequest"
multi_selected: "#/components/schemas/SubmitAnswerMultiSelectedRequest"
text: "#/components/schemas/SubmitAnswerTextRequest"
SubmitAnswerYesRequest:
type: object
required:
- kind
properties:
value:
kind:
type: string
description: Freeform answer text.
example: "Yes, proceed with the changes."
selected_option_key:
enum: ["yes"]
description: Affirmative answer for yes/no and confirmation questions.
SubmitAnswerNoRequest:
type: object
required:
- kind
properties:
kind:
type: string
description: Key of the selected option (for single-select multiple-choice questions).
enum: ["no"]
description: Negative answer for yes/no questions.
SubmitAnswerSelectedRequest:
type: object
required:
- kind
- option_key
properties:
kind:
type: string
enum: [selected]
description: Single selected option answer.
option_key:
type: string
description: Key of the selected option.
example: option_a
selected_option_keys:
SubmitAnswerMultiSelectedRequest:
type: object
required:
- kind
- option_keys
properties:
kind:
type: string
enum: [multi_selected]
description: Multiple selected option answer.
option_keys:
type: array
items:
type: string
description: Keys of selected options (for multi-select questions).
description: Keys of selected options.
example: ["option_a", "option_b"]
SubmitAnswerTextRequest:
type: object
required:
- kind
- text
properties:
kind:
type: string
enum: [text]
description: Freeform text answer.
text:
type: string
description: Freeform answer text.
example: "Yes, proceed with the changes."
ErrorResponseEntry:
description: A single error entry in an error response.
type: object

View file

@ -0,0 +1,25 @@
use fabro_api::types::SubmitAnswerRequest;
use serde_json::json;
#[test]
fn typed_answer_variants_round_trip_through_json() {
let cases = [
json!({ "kind": "yes" }),
json!({ "kind": "no" }),
json!({ "kind": "selected", "option_key": "approve" }),
json!({ "kind": "multi_selected", "option_keys": ["approve", "notify"] }),
json!({ "kind": "text", "text": "Looks good to me." }),
];
for payload in cases {
let request: SubmitAnswerRequest = serde_json::from_value(payload.clone()).unwrap();
assert_eq!(serde_json::to_value(request).unwrap(), payload);
}
}
#[test]
fn legacy_answer_fields_are_not_part_of_the_wire_contract() {
let result = serde_json::from_value::<SubmitAnswerRequest>(json!({ "value": "yes" }));
assert!(result.is_err());
}

View file

@ -313,12 +313,30 @@ async fn submit_server_interview_answer(
qid: &str,
answer: &fabro_interview::Answer,
) -> Result<bool> {
let (value, selected_option_key, selected_option_keys) = match &answer.value {
AnswerValue::Text(text) => (Some(text.clone()), None, Vec::new()),
AnswerValue::Selected(key) => (None, Some(key.clone()), Vec::new()),
AnswerValue::MultiSelected(keys) => (None, None, keys.clone()),
AnswerValue::Yes => (Some("yes".to_string()), None, Vec::new()),
AnswerValue::No => (Some("no".to_string()), None, Vec::new()),
let body = match &answer.value {
AnswerValue::Text(text) => types::SubmitAnswerTextRequest {
kind: types::SubmitAnswerTextRequestKind::Text,
text: text.clone(),
}
.into(),
AnswerValue::Selected(key) => types::SubmitAnswerSelectedRequest {
kind: types::SubmitAnswerSelectedRequestKind::Selected,
option_key: key.clone(),
}
.into(),
AnswerValue::MultiSelected(keys) => types::SubmitAnswerMultiSelectedRequest {
kind: types::SubmitAnswerMultiSelectedRequestKind::MultiSelected,
option_keys: keys.clone(),
}
.into(),
AnswerValue::Yes => types::SubmitAnswerYesRequest {
kind: types::SubmitAnswerYesRequestKind::Yes,
}
.into(),
AnswerValue::No => types::SubmitAnswerNoRequest {
kind: types::SubmitAnswerNoRequestKind::No,
}
.into(),
AnswerValue::Cancelled
| AnswerValue::Interrupted
| AnswerValue::Skipped
@ -326,15 +344,7 @@ async fn submit_server_interview_answer(
return Ok(false);
}
};
client
.submit_run_answer(
run_id,
qid,
value,
selected_option_key,
selected_option_keys,
)
.await?;
client.submit_run_answer(run_id, qid, body).await?;
Ok(true)
}

View file

@ -977,7 +977,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
.post(format!(
"{base_url}/api/v1/runs/{run_id}/questions/{question_id}/answer"
))
.json(&serde_json::json!({ "selected_option_key": "A" }))
.json(&serde_json::json!({ "kind": "selected", "option_key": "A" }))
.send()
.await
.expect("answer submission should succeed");

View file

@ -781,7 +781,7 @@ fn detached_run_answers_pending_question_without_interview_scratch_files() {
.post(format!(
"{base_url}/api/v1/runs/{run_id}/questions/{question_id}/answer"
))
.json(&serde_json::json!({ "selected_option_key": "A" }))
.json(&serde_json::json!({ "kind": "selected", "option_key": "A" }))
.send()
.await
.expect("answer submission should succeed");

View file

@ -1159,20 +1159,14 @@ impl Client {
&self,
run_id: &RunId,
qid: &str,
value: Option<String>,
selected_option_key: Option<String>,
selected_option_keys: Vec<String>,
body: types::SubmitAnswerRequest,
) -> Result<()> {
self.send_api(|client| async move {
client
.submit_run_answer()
.id(run_id.to_string())
.qid(qid)
.body(types::SubmitAnswerRequest {
value: value.clone(),
selected_option_key: selected_option_key.clone(),
selected_option_keys: selected_option_keys.clone(),
})
.body(body.clone())
.send()
.await
})

View file

@ -2590,9 +2590,10 @@ fn validate_answer_for_question(
) -> Result<(), Response> {
match (&question.question_type, &answer.value) {
(
QuestionType::YesNo | QuestionType::Confirmation,
QuestionType::YesNo,
fabro_interview::AnswerValue::Yes | fabro_interview::AnswerValue::No,
)
| (QuestionType::Confirmation, fabro_interview::AnswerValue::Yes)
| (
_,
fabro_interview::AnswerValue::Interrupted
@ -2692,31 +2693,31 @@ fn answer_from_request(
req: SubmitAnswerRequest,
question: &InterviewQuestionRecord,
) -> Result<Answer, Response> {
if let Some(key) = req.selected_option_key {
let option = question
.options
.iter()
.find(|option| option.key == key)
.cloned();
match option {
Some(option) => Ok(Answer::selected(key, option)),
None => Err(ApiError::bad_request("Invalid option key.").into_response()),
}
} else if !req.selected_option_keys.is_empty() {
for key in &req.selected_option_keys {
let valid = question.options.iter().any(|option| option.key == *key);
if !valid {
return Err(ApiError::bad_request("Invalid option key.").into_response());
match req {
SubmitAnswerRequest::YesRequest(_) => Ok(Answer::yes()),
SubmitAnswerRequest::NoRequest(_) => Ok(Answer::no()),
SubmitAnswerRequest::SelectedRequest(req) => {
let key = req.option_key;
let option = question
.options
.iter()
.find(|option| option.key == key)
.cloned();
match option {
Some(option) => Ok(Answer::selected(key, option)),
None => Err(ApiError::bad_request("Invalid option key.").into_response()),
}
}
Ok(Answer::multi_selected(req.selected_option_keys))
} else if let Some(value) = req.value {
Ok(Answer::text(value))
} else {
Err(ApiError::bad_request(
"One of value, selected_option_key, or selected_option_keys is required.",
)
.into_response())
SubmitAnswerRequest::MultiSelectedRequest(req) => {
for key in &req.option_keys {
let valid = question.options.iter().any(|option| option.key == *key);
if !valid {
return Err(ApiError::bad_request("Invalid option key.").into_response());
}
}
Ok(Answer::multi_selected(req.option_keys))
}
SubmitAnswerRequest::TextRequest(req) => Ok(Answer::text(req.text)),
}
}

View file

@ -3983,7 +3983,7 @@ async fn submit_answer_not_found_run() {
.uri(api(&format!("/runs/{missing_run_id}/questions/q1/answer")))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"value": "yes"})).unwrap(),
serde_json::to_string(&serde_json::json!({"kind": "yes"})).unwrap(),
))
.unwrap();
@ -4026,6 +4026,109 @@ async fn submit_pending_interview_answer_rejects_invalid_answer_shape() {
assert_status!(response, StatusCode::BAD_REQUEST).await;
}
#[test]
fn answer_from_typed_yes_request_maps_to_yes_answer() {
let question = InterviewQuestionRecord {
id: "q-1".to_string(),
text: "Continue?".to_string(),
stage: "gate".to_string(),
question_type: QuestionType::YesNo,
options: vec![],
allow_freeform: false,
timeout_seconds: None,
context_display: None,
};
let req: SubmitAnswerRequest = serde_json::from_value(json!({ "kind": "yes" })).unwrap();
let answer = answer_from_request(req, &question).unwrap();
assert_eq!(answer.value, AnswerValue::Yes);
}
#[test]
fn answer_from_typed_no_request_maps_to_no_answer() {
let question = InterviewQuestionRecord {
id: "q-1".to_string(),
text: "Continue?".to_string(),
stage: "gate".to_string(),
question_type: QuestionType::YesNo,
options: vec![],
allow_freeform: false,
timeout_seconds: None,
context_display: None,
};
let req: SubmitAnswerRequest = serde_json::from_value(json!({ "kind": "no" })).unwrap();
let answer = answer_from_request(req, &question).unwrap();
assert_eq!(answer.value, AnswerValue::No);
}
#[test]
fn answer_from_typed_selected_request_validates_and_attaches_option() {
let question = InterviewQuestionRecord {
id: "q-1".to_string(),
text: "Choose one.".to_string(),
stage: "gate".to_string(),
question_type: QuestionType::MultipleChoice,
options: vec![fabro_types::run_event::InterviewOption {
key: "approve".to_string(),
label: "Approve".to_string(),
}],
allow_freeform: false,
timeout_seconds: None,
context_display: None,
};
let req: SubmitAnswerRequest =
serde_json::from_value(json!({ "kind": "selected", "option_key": "approve" })).unwrap();
let answer = answer_from_request(req, &question).unwrap();
assert_eq!(answer.value, AnswerValue::Selected("approve".to_string()));
assert_eq!(
answer
.selected_option
.as_ref()
.map(|option| option.label.as_str()),
Some("Approve")
);
}
#[test]
fn answer_from_typed_multi_selected_request_validates_option_keys() {
let question = InterviewQuestionRecord {
id: "q-1".to_string(),
text: "Choose many.".to_string(),
stage: "gate".to_string(),
question_type: QuestionType::MultiSelect,
options: vec![
fabro_types::run_event::InterviewOption {
key: "approve".to_string(),
label: "Approve".to_string(),
},
fabro_types::run_event::InterviewOption {
key: "notify".to_string(),
label: "Notify".to_string(),
},
],
allow_freeform: false,
timeout_seconds: None,
context_display: None,
};
let req: SubmitAnswerRequest = serde_json::from_value(json!({
"kind": "multi_selected",
"option_keys": ["approve", "notify"],
}))
.unwrap();
let answer = answer_from_request(req, &question).unwrap();
assert_eq!(
answer.value,
AnswerValue::MultiSelected(vec!["approve".to_string(), "notify".to_string()])
);
}
#[tokio::test]
async fn get_events_not_found() {
let app = test_app_with();
@ -8106,7 +8209,7 @@ async fn submit_answer_to_queued_run_returns_conflict() {
.uri(api(&format!("/runs/{run_id}/questions/q1/answer")))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"value": "yes"})).unwrap(),
serde_json::to_string(&serde_json::json!({"kind": "yes"})).unwrap(),
))
.unwrap();

View file

@ -169,7 +169,11 @@ async fn full_http_lifecycle_approve_and_complete() {
)))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"selected_option_key": "A"})).unwrap(),
serde_json::to_string(&serde_json::json!({
"kind": "selected",
"option_key": "A",
}))
.unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();

View file

@ -312,7 +312,12 @@ models/stage-projection.ts
models/stage-state.ts
models/start-run-request.ts
models/steer-run-request.ts
models/submit-answer-multi-selected-request.ts
models/submit-answer-no-request.ts
models/submit-answer-request.ts
models/submit-answer-selected-request.ts
models/submit-answer-text-request.ts
models/submit-answer-yes-request.ts
models/success-reason.ts
models/system-actor-kind.ts
models/system-features.ts

View file

@ -289,7 +289,12 @@ export * from './stage-projection';
export * from './stage-state';
export * from './start-run-request';
export * from './steer-run-request';
export * from './submit-answer-multi-selected-request';
export * from './submit-answer-no-request';
export * from './submit-answer-request';
export * from './submit-answer-selected-request';
export * from './submit-answer-text-request';
export * from './submit-answer-yes-request';
export * from './success-reason';
export * from './system-actor-kind';
export * from './system-features';

View file

@ -0,0 +1,34 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface SubmitAnswerMultiSelectedRequest {
/**
* Multiple selected option answer.
*/
'kind': SubmitAnswerMultiSelectedRequestKindEnum;
/**
* Keys of selected options.
*/
'option_keys': Array<string>;
}
export const SubmitAnswerMultiSelectedRequestKindEnum = {
MULTI_SELECTED: 'multi_selected'
} as const;
export type SubmitAnswerMultiSelectedRequestKindEnum = typeof SubmitAnswerMultiSelectedRequestKindEnum[keyof typeof SubmitAnswerMultiSelectedRequestKindEnum];

View file

@ -0,0 +1,30 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface SubmitAnswerNoRequest {
/**
* Negative answer for yes/no questions.
*/
'kind': SubmitAnswerNoRequestKindEnum;
}
export const SubmitAnswerNoRequestKindEnum = {
NO: 'no'
} as const;
export type SubmitAnswerNoRequestKindEnum = typeof SubmitAnswerNoRequestKindEnum[keyof typeof SubmitAnswerNoRequestKindEnum];

View file

@ -13,22 +13,26 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { SubmitAnswerMultiSelectedRequest } from './submit-answer-multi-selected-request';
// May contain unused imports in some cases
// @ts-ignore
import type { SubmitAnswerNoRequest } from './submit-answer-no-request';
// May contain unused imports in some cases
// @ts-ignore
import type { SubmitAnswerSelectedRequest } from './submit-answer-selected-request';
// May contain unused imports in some cases
// @ts-ignore
import type { SubmitAnswerTextRequest } from './submit-answer-text-request';
// May contain unused imports in some cases
// @ts-ignore
import type { SubmitAnswerYesRequest } from './submit-answer-yes-request';
/**
* Request body for submitting an answer to a pending question. At least one of `value`, `selected_option_key`, or `selected_option_keys` must be provided.
* @type SubmitAnswerRequest
* Request body for submitting an answer to a pending question. The `kind` discriminator determines which answer shape is submitted.
*/
export interface SubmitAnswerRequest {
/**
* Freeform answer text.
*/
'value'?: string;
/**
* Key of the selected option (for single-select multiple-choice questions).
*/
'selected_option_key'?: string;
/**
* Keys of selected options (for multi-select questions).
*/
'selected_option_keys'?: Array<string>;
}
export type SubmitAnswerRequest = { kind: 'multi_selected' } & SubmitAnswerMultiSelectedRequest | { kind: 'no' } & SubmitAnswerNoRequest | { kind: 'selected' } & SubmitAnswerSelectedRequest | { kind: 'text' } & SubmitAnswerTextRequest | { kind: 'yes' } & SubmitAnswerYesRequest;

View file

@ -0,0 +1,34 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface SubmitAnswerSelectedRequest {
/**
* Single selected option answer.
*/
'kind': SubmitAnswerSelectedRequestKindEnum;
/**
* Key of the selected option.
*/
'option_key': string;
}
export const SubmitAnswerSelectedRequestKindEnum = {
SELECTED: 'selected'
} as const;
export type SubmitAnswerSelectedRequestKindEnum = typeof SubmitAnswerSelectedRequestKindEnum[keyof typeof SubmitAnswerSelectedRequestKindEnum];

View file

@ -0,0 +1,34 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface SubmitAnswerTextRequest {
/**
* Freeform text answer.
*/
'kind': SubmitAnswerTextRequestKindEnum;
/**
* Freeform answer text.
*/
'text': string;
}
export const SubmitAnswerTextRequestKindEnum = {
TEXT: 'text'
} as const;
export type SubmitAnswerTextRequestKindEnum = typeof SubmitAnswerTextRequestKindEnum[keyof typeof SubmitAnswerTextRequestKindEnum];

View file

@ -0,0 +1,30 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface SubmitAnswerYesRequest {
/**
* Affirmative answer for yes/no and confirmation questions.
*/
'kind': SubmitAnswerYesRequestKindEnum;
}
export const SubmitAnswerYesRequestKindEnum = {
YES: 'yes'
} as const;
export type SubmitAnswerYesRequestKindEnum = typeof SubmitAnswerYesRequestKindEnum[keyof typeof SubmitAnswerYesRequestKindEnum];