fix(httpapi): omit absent optional response fields (#25214)
This commit is contained in:
parent
4c70ea28d2
commit
3c24d22d42
4 changed files with 118 additions and 35 deletions
|
|
@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||||
import { serviceUse } from "@/effect/service-use"
|
import { serviceUse } from "@/effect/service-use"
|
||||||
|
|
||||||
const log = Log.create({ service: "project" })
|
const log = Log.create({ service: "project" })
|
||||||
|
|
@ -24,13 +24,13 @@ const log = Log.create({ service: "project" })
|
||||||
const ProjectVcs = Schema.Literal("git")
|
const ProjectVcs = Schema.Literal("git")
|
||||||
|
|
||||||
const ProjectIcon = Schema.Struct({
|
const ProjectIcon = Schema.Struct({
|
||||||
url: Schema.optional(Schema.String),
|
url: optionalOmitUndefined(Schema.String),
|
||||||
override: Schema.optional(Schema.String),
|
override: optionalOmitUndefined(Schema.String),
|
||||||
color: Schema.optional(Schema.String),
|
color: optionalOmitUndefined(Schema.String),
|
||||||
})
|
})
|
||||||
|
|
||||||
const ProjectCommands = Schema.Struct({
|
const ProjectCommands = Schema.Struct({
|
||||||
start: Schema.optional(
|
start: optionalOmitUndefined(
|
||||||
Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
|
Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
@ -38,16 +38,16 @@ const ProjectCommands = Schema.Struct({
|
||||||
const ProjectTime = Schema.Struct({
|
const ProjectTime = Schema.Struct({
|
||||||
created: NonNegativeInt,
|
created: NonNegativeInt,
|
||||||
updated: NonNegativeInt,
|
updated: NonNegativeInt,
|
||||||
initialized: Schema.optional(NonNegativeInt),
|
initialized: optionalOmitUndefined(NonNegativeInt),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Info = Schema.Struct({
|
export const Info = Schema.Struct({
|
||||||
id: ProjectID,
|
id: ProjectID,
|
||||||
worktree: Schema.String,
|
worktree: Schema.String,
|
||||||
vcs: Schema.optional(ProjectVcs),
|
vcs: optionalOmitUndefined(ProjectVcs),
|
||||||
name: Schema.optional(Schema.String),
|
name: optionalOmitUndefined(Schema.String),
|
||||||
icon: Schema.optional(ProjectIcon),
|
icon: optionalOmitUndefined(ProjectIcon),
|
||||||
commands: Schema.optional(ProjectCommands),
|
commands: optionalOmitUndefined(ProjectCommands),
|
||||||
time: ProjectTime,
|
time: ProjectTime,
|
||||||
sandboxes: Schema.Array(Schema.String),
|
sandboxes: Schema.Array(Schema.String),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Auth } from "@/auth"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { namedSchemaError } from "@/util/named-schema-error"
|
import { namedSchemaError } from "@/util/named-schema-error"
|
||||||
import { withStatics } from "@/util/schema"
|
import { optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
import { ProviderID } from "./schema"
|
import { ProviderID } from "./schema"
|
||||||
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
|
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
|
||||||
|
|
@ -18,14 +18,14 @@ const TextPrompt = Schema.Struct({
|
||||||
type: Schema.Literal("text"),
|
type: Schema.Literal("text"),
|
||||||
key: Schema.String,
|
key: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
placeholder: Schema.optional(Schema.String),
|
placeholder: optionalOmitUndefined(Schema.String),
|
||||||
when: Schema.optional(When),
|
when: optionalOmitUndefined(When),
|
||||||
})
|
})
|
||||||
|
|
||||||
const SelectOption = Schema.Struct({
|
const SelectOption = Schema.Struct({
|
||||||
label: Schema.String,
|
label: Schema.String,
|
||||||
value: Schema.String,
|
value: Schema.String,
|
||||||
hint: Schema.optional(Schema.String),
|
hint: optionalOmitUndefined(Schema.String),
|
||||||
})
|
})
|
||||||
|
|
||||||
const SelectPrompt = Schema.Struct({
|
const SelectPrompt = Schema.Struct({
|
||||||
|
|
@ -33,7 +33,7 @@ const SelectPrompt = Schema.Struct({
|
||||||
key: Schema.String,
|
key: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
options: Schema.Array(SelectOption),
|
options: Schema.Array(SelectOption),
|
||||||
when: Schema.optional(When),
|
when: optionalOmitUndefined(When),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Prompt = Schema.Union([TextPrompt, SelectPrompt])
|
const Prompt = Schema.Union([TextPrompt, SelectPrompt])
|
||||||
|
|
@ -41,7 +41,7 @@ const Prompt = Schema.Union([TextPrompt, SelectPrompt])
|
||||||
export class Method extends Schema.Class<Method>("ProviderAuthMethod")({
|
export class Method extends Schema.Class<Method>("ProviderAuthMethod")({
|
||||||
type: Schema.Literals(["oauth", "api"]),
|
type: Schema.Literals(["oauth", "api"]),
|
||||||
label: Schema.String,
|
label: Schema.String,
|
||||||
prompts: Schema.optional(Schema.Array(Prompt)),
|
prompts: optionalOmitUndefined(Schema.Array(Prompt)),
|
||||||
}) {
|
}) {
|
||||||
static readonly zod = zod(this)
|
static readonly zod = zod(this)
|
||||||
}
|
}
|
||||||
|
|
@ -135,23 +135,25 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
|
||||||
item.methods.map((method) => ({
|
item.methods.map((method) => ({
|
||||||
type: method.type,
|
type: method.type,
|
||||||
label: method.label,
|
label: method.label,
|
||||||
prompts: method.prompts?.map((prompt) => {
|
...(method.prompts && {
|
||||||
if (prompt.type === "select") {
|
prompts: method.prompts.map((prompt) => {
|
||||||
|
if (prompt.type === "select") {
|
||||||
|
return {
|
||||||
|
type: "select" as const,
|
||||||
|
key: prompt.key,
|
||||||
|
message: prompt.message,
|
||||||
|
options: prompt.options,
|
||||||
|
...(prompt.when && { when: prompt.when }),
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: "select" as const,
|
type: "text" as const,
|
||||||
key: prompt.key,
|
key: prompt.key,
|
||||||
message: prompt.message,
|
message: prompt.message,
|
||||||
options: prompt.options,
|
...(prompt.placeholder && { placeholder: prompt.placeholder }),
|
||||||
when: prompt.when,
|
...(prompt.when && { when: prompt.when }),
|
||||||
}
|
}
|
||||||
}
|
}),
|
||||||
return {
|
|
||||||
type: "text" as const,
|
|
||||||
key: prompt.key,
|
|
||||||
message: prompt.message,
|
|
||||||
placeholder: prompt.placeholder,
|
|
||||||
when: prompt.when,
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import { EffectBridge } from "@/effect/bridge"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { withStatics } from "@/util/schema"
|
import { optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||||
|
|
||||||
import * as ProviderTransform from "./transform"
|
import * as ProviderTransform from "./transform"
|
||||||
import { ModelID, ProviderID } from "./schema"
|
import { ModelID, ProviderID } from "./schema"
|
||||||
|
|
@ -875,7 +875,7 @@ const ProviderCost = Schema.Struct({
|
||||||
input: Schema.Finite,
|
input: Schema.Finite,
|
||||||
output: Schema.Finite,
|
output: Schema.Finite,
|
||||||
cache: ProviderCacheCost,
|
cache: ProviderCacheCost,
|
||||||
experimentalOver200K: Schema.optional(
|
experimentalOver200K: optionalOmitUndefined(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
input: Schema.Finite,
|
input: Schema.Finite,
|
||||||
output: Schema.Finite,
|
output: Schema.Finite,
|
||||||
|
|
@ -886,7 +886,7 @@ const ProviderCost = Schema.Struct({
|
||||||
|
|
||||||
const ProviderLimit = Schema.Struct({
|
const ProviderLimit = Schema.Struct({
|
||||||
context: Schema.Finite,
|
context: Schema.Finite,
|
||||||
input: Schema.optional(Schema.Finite),
|
input: optionalOmitUndefined(Schema.Finite),
|
||||||
output: Schema.Finite,
|
output: Schema.Finite,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -895,7 +895,7 @@ export const Model = Schema.Struct({
|
||||||
providerID: ProviderID,
|
providerID: ProviderID,
|
||||||
api: ProviderApiInfo,
|
api: ProviderApiInfo,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
family: Schema.optional(Schema.String),
|
family: optionalOmitUndefined(Schema.String),
|
||||||
capabilities: ProviderCapabilities,
|
capabilities: ProviderCapabilities,
|
||||||
cost: ProviderCost,
|
cost: ProviderCost,
|
||||||
limit: ProviderLimit,
|
limit: ProviderLimit,
|
||||||
|
|
@ -903,7 +903,7 @@ export const Model = Schema.Struct({
|
||||||
options: Schema.Record(Schema.String, Schema.Any),
|
options: Schema.Record(Schema.String, Schema.Any),
|
||||||
headers: Schema.Record(Schema.String, Schema.String),
|
headers: Schema.Record(Schema.String, Schema.String),
|
||||||
release_date: Schema.String,
|
release_date: Schema.String,
|
||||||
variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
|
variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "Model" })
|
.annotate({ identifier: "Model" })
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
|
@ -914,7 +914,7 @@ export const Info = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
source: Schema.Literals(["env", "config", "custom", "api"]),
|
source: Schema.Literals(["env", "config", "custom", "api"]),
|
||||||
env: Schema.Array(Schema.String),
|
env: Schema.Array(Schema.String),
|
||||||
key: Schema.optional(Schema.String),
|
key: optionalOmitUndefined(Schema.String),
|
||||||
options: Schema.Record(Schema.String, Schema.Any),
|
options: Schema.Record(Schema.String, Schema.Any),
|
||||||
models: Schema.Record(Schema.String, Model),
|
models: Schema.Record(Schema.String, Model),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Server } from "../../src/server/server"
|
import { Server } from "../../src/server/server"
|
||||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||||
|
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||||
|
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
||||||
|
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||||
|
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
|
||||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||||
import { MessageID, PartID } from "../../src/session/schema"
|
import { MessageID, PartID } from "../../src/session/schema"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
|
|
@ -89,6 +93,83 @@ afterEach(async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("HttpApi JSON parity", () => {
|
describe("HttpApi JSON parity", () => {
|
||||||
|
it.live(
|
||||||
|
"matches legacy JSON shape for safe GET endpoints",
|
||||||
|
withTmp(
|
||||||
|
{
|
||||||
|
git: true,
|
||||||
|
config: {
|
||||||
|
formatter: false,
|
||||||
|
lsp: false,
|
||||||
|
mcp: {
|
||||||
|
demo: {
|
||||||
|
type: "local",
|
||||||
|
command: ["echo", "demo"],
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n"))
|
||||||
|
|
||||||
|
const headers = { "x-opencode-directory": tmp.path }
|
||||||
|
const legacy = app(false)
|
||||||
|
const httpapi = app(true)
|
||||||
|
|
||||||
|
yield* Effect.forEach(
|
||||||
|
[
|
||||||
|
{ label: "global.health", path: GlobalPaths.health, headers: {} },
|
||||||
|
{ label: "instance.path", path: InstancePaths.path, headers },
|
||||||
|
{ label: "instance.vcs", path: InstancePaths.vcs, headers },
|
||||||
|
{ label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers },
|
||||||
|
{ label: "instance.command", path: InstancePaths.command, headers },
|
||||||
|
{ label: "instance.agent", path: InstancePaths.agent, headers },
|
||||||
|
{ label: "instance.skill", path: InstancePaths.skill, headers },
|
||||||
|
{ label: "instance.lsp", path: InstancePaths.lsp, headers },
|
||||||
|
{ label: "instance.formatter", path: InstancePaths.formatter, headers },
|
||||||
|
{ label: "config.get", path: "/config", headers },
|
||||||
|
{ label: "config.providers", path: "/config/providers", headers },
|
||||||
|
{ label: "project.list", path: "/project", headers },
|
||||||
|
{ label: "project.current", path: "/project/current", headers },
|
||||||
|
{ label: "provider.list", path: "/provider", headers },
|
||||||
|
{ label: "provider.auth", path: "/provider/auth", headers },
|
||||||
|
{ label: "mcp.status", path: McpPaths.status, headers },
|
||||||
|
{ label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers },
|
||||||
|
{
|
||||||
|
label: "file.content",
|
||||||
|
path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
{ label: "file.status", path: FilePaths.status, headers },
|
||||||
|
{
|
||||||
|
label: "find.file",
|
||||||
|
path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "find.text",
|
||||||
|
path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "find.symbol",
|
||||||
|
path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
{ label: "experimental.console", path: ExperimentalPaths.console, headers },
|
||||||
|
{ label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers },
|
||||||
|
{ label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers },
|
||||||
|
{ label: "experimental.worktree", path: ExperimentalPaths.worktree, headers },
|
||||||
|
],
|
||||||
|
(input) => expectJsonParity({ ...input, legacy, httpapi }),
|
||||||
|
{ concurrency: 1 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live(
|
it.live(
|
||||||
"matches legacy JSON shape for session read endpoints",
|
"matches legacy JSON shape for session read endpoints",
|
||||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue