Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import * as JsonSchemaGenerator from "@effect/openapi-generator/JsonSchemaGenerator"
|
||||
import { describe, expect, it } from "@effect/vitest"
|
||||
|
||||
describe("JsonSchemaGenerator", () => {
|
||||
it("schema & no definitions", () => {
|
||||
const generator = JsonSchemaGenerator.make()
|
||||
generator.addSchema("A", { type: "string" })
|
||||
const definitions = {}
|
||||
const result = generator.generate("openapi-3.1", definitions, false)
|
||||
expect(result).toBe(`// schemas
|
||||
export type A = string
|
||||
export const A = Schema.String
|
||||
`)
|
||||
})
|
||||
|
||||
it("schema & definitions", () => {
|
||||
const generator = JsonSchemaGenerator.make()
|
||||
generator.addSchema("A", { $ref: "#/components/schemas/B" })
|
||||
const definitions = {
|
||||
B: { type: "string" }
|
||||
}
|
||||
const result = generator.generate("openapi-3.1", definitions, false)
|
||||
expect(result).toBe(`// non-recursive definitions
|
||||
export type B = string
|
||||
export const B = Schema.String
|
||||
// schemas
|
||||
export type A = B
|
||||
export const A = B
|
||||
`)
|
||||
})
|
||||
|
||||
it("onEnter strips specified keys", () => {
|
||||
const generator = JsonSchemaGenerator.make()
|
||||
generator.addSchema("A", { type: "string", description: "desc", examples: ["ex"] })
|
||||
const definitions = {}
|
||||
const result = generator.generate("openapi-3.1", definitions, false, {
|
||||
onEnter: (js) => {
|
||||
const out = { ...js }
|
||||
delete out.examples
|
||||
return out
|
||||
}
|
||||
})
|
||||
expect(result).toBe(`// schemas
|
||||
export type A = string
|
||||
export const A = Schema.String.annotate({ "description": "desc" })
|
||||
`)
|
||||
})
|
||||
|
||||
it("default preserves all annotations", () => {
|
||||
const generator = JsonSchemaGenerator.make()
|
||||
generator.addSchema("A", { type: "string", description: "desc", examples: ["ex"] })
|
||||
const definitions = {}
|
||||
const result = generator.generate("openapi-3.1", definitions, false)
|
||||
expect(result).toBe(`// schemas
|
||||
export type A = string
|
||||
export const A = Schema.String.annotate({ "description": "desc", "examples": ["ex"] })
|
||||
`)
|
||||
})
|
||||
|
||||
it("generateHttpApi emits explicit type and const declarations", () => {
|
||||
const generator = JsonSchemaGenerator.make()
|
||||
generator.addSchema("A", { type: "string" })
|
||||
generator.addSchema("B", {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
required: ["id"],
|
||||
additionalProperties: false
|
||||
})
|
||||
|
||||
const result = generator.generateHttpApi("openapi-3.1", {})
|
||||
|
||||
expect(result).toContain(`export type A = string
|
||||
export const A = Schema.String`)
|
||||
expect(result).toContain(`export type B = { readonly "id": string }
|
||||
export const B = Schema.Struct({ "id": Schema.String })`)
|
||||
expect(result).not.toContain("Schema.Class<")
|
||||
expect(result).not.toContain("Schema.Opaque<")
|
||||
})
|
||||
|
||||
it("recursive schema", () => {
|
||||
const generator = JsonSchemaGenerator.make()
|
||||
generator.addSchema("A", { $ref: "#/components/schemas/B" })
|
||||
const definitions = {
|
||||
B: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/B"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"children"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
const result = generator.generate("openapi-3.1", definitions, false)
|
||||
expect(result).toBe(`// recursive definitions
|
||||
export type B = { readonly "name": string, readonly "children": ReadonlyArray<B> }
|
||||
export const B = Schema.Struct({ "name": Schema.String, "children": Schema.Array(Schema.suspend((): Schema.Codec<B> => B)) })
|
||||
// schemas
|
||||
export type A = B
|
||||
export const A = B
|
||||
`)
|
||||
})
|
||||
|
||||
it("hoists recursive definitions referenced by earlier recursive definitions", () => {
|
||||
const generator = JsonSchemaGenerator.make()
|
||||
generator.addSchema("A", { $ref: "#/components/schemas/ResourcesNetworkCard" })
|
||||
const definitions = {
|
||||
ResourcesNetworkCard: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sriov: {
|
||||
$ref: "#/components/schemas/ResourcesNetworkCardSRIOV"
|
||||
}
|
||||
},
|
||||
additionalProperties: false
|
||||
},
|
||||
ResourcesNetworkCardSRIOV: {
|
||||
type: "object",
|
||||
properties: {
|
||||
vfs: {
|
||||
type: "array",
|
||||
items: {
|
||||
$ref: "#/components/schemas/ResourcesNetworkCard"
|
||||
}
|
||||
}
|
||||
},
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
|
||||
const result = generator.generate("openapi-3.1", definitions, false)
|
||||
const recursiveDeclaration =
|
||||
"export const ResourcesNetworkCardSRIOV = Schema.suspend((): Schema.Codec<ResourcesNetworkCardSRIOV> => __recursive_ResourcesNetworkCardSRIOV)"
|
||||
|
||||
expect(result).toContain(recursiveDeclaration)
|
||||
expect(result).toContain("const __recursive_ResourcesNetworkCardSRIOV =")
|
||||
expect(result.indexOf(recursiveDeclaration)).toBeLessThan(
|
||||
result.indexOf("export const ResourcesNetworkCard =")
|
||||
)
|
||||
expect(result.indexOf("export const ResourcesNetworkCard =")).toBeLessThan(
|
||||
result.indexOf("const __recursive_ResourcesNetworkCardSRIOV =")
|
||||
)
|
||||
})
|
||||
|
||||
it("renders recursive definitions before non-recursive references for runtime generation", () => {
|
||||
const generator = JsonSchemaGenerator.make()
|
||||
generator.addSchema("A", { $ref: "#/components/schemas/ErrorResponse" })
|
||||
const definitions = {
|
||||
InnerErrors: {
|
||||
type: "object",
|
||||
properties: {
|
||||
field: {
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
required: ["field"],
|
||||
additionalProperties: false
|
||||
},
|
||||
ErrorDetails: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
$ref: "#/components/schemas/ErrorDetails"
|
||||
}
|
||||
},
|
||||
{
|
||||
$ref: "#/components/schemas/InnerErrors"
|
||||
}
|
||||
]
|
||||
},
|
||||
ErrorResponse: {
|
||||
type: "object",
|
||||
properties: {
|
||||
errors: {
|
||||
$ref: "#/components/schemas/ErrorDetails"
|
||||
}
|
||||
},
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeResult = generator.generate("openapi-3.1", definitions, false)
|
||||
const recursiveDeclaration =
|
||||
"export const ErrorDetails = Schema.suspend((): Schema.Codec<ErrorDetails> => __recursive_ErrorDetails)"
|
||||
|
||||
expect(runtimeResult).toContain(recursiveDeclaration)
|
||||
expect(runtimeResult).toContain("const __recursive_ErrorDetails =")
|
||||
expect(runtimeResult.indexOf(recursiveDeclaration)).toBeLessThan(
|
||||
runtimeResult.indexOf("export const ErrorResponse =")
|
||||
)
|
||||
|
||||
const httpApiResult = generator.generateHttpApi("openapi-3.1", definitions)
|
||||
expect(httpApiResult).toContain(recursiveDeclaration)
|
||||
expect(httpApiResult).toContain("const __recursive_ErrorDetails =")
|
||||
expect(httpApiResult.indexOf(recursiveDeclaration)).toBeLessThan(
|
||||
httpApiResult.indexOf("export const ErrorResponse =")
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
import * as NodeServices from "@effect/platform-node/NodeServices"
|
||||
import { assert, describe, it } from "@effect/vitest"
|
||||
import { Effect, Layer, Stdio, Stream } from "effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
import { TestConsole } from "effect/testing"
|
||||
import { CliOutput } from "effect/unstable/cli"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
|
||||
const makeLayer = (args: ReadonlyArray<string>) =>
|
||||
Layer.mergeAll(
|
||||
TestConsole.layer,
|
||||
CliOutput.layer(CliOutput.defaultFormatter({ colors: false })),
|
||||
NodeServices.layer,
|
||||
Stdio.layerTest({ args: Effect.succeed(args) })
|
||||
)
|
||||
|
||||
const fixturePath = (fileName: string) => `${import.meta.dirname}/fixtures/${fileName}`
|
||||
const cliProcessPath = `${import.meta.dirname}/../src/bin.ts`
|
||||
|
||||
type CliMainModule = {
|
||||
readonly run: Effect.Effect<void>
|
||||
}
|
||||
|
||||
const runCli = Effect.fnUntraced(function*(args: ReadonlyArray<string>) {
|
||||
const module = (yield* Effect.promise(
|
||||
() => import(new URL("../src/main.ts", import.meta.url).href)
|
||||
)) as CliMainModule
|
||||
|
||||
return yield* Effect.gen(function*() {
|
||||
const exit = yield* Effect.exit(module.run)
|
||||
const stdoutLines = yield* TestConsole.logLines
|
||||
const stderrLines = yield* TestConsole.errorLines
|
||||
const stdout = stdoutLines.length > 0 ? String(stdoutLines[stdoutLines.length - 1]) : ""
|
||||
const stderr = stderrLines.map(String).join("\n")
|
||||
return { exit, stdout, stderr } as const
|
||||
}).pipe(Effect.provide(makeLayer(args)))
|
||||
})
|
||||
|
||||
const runCliProcess = Effect.fnUntraced(function*(args: ReadonlyArray<string>) {
|
||||
const handle = yield* ChildProcess.make("node", [cliProcessPath, ...args])
|
||||
return yield* Effect.all({
|
||||
exitCode: handle.exitCode,
|
||||
stdout: Stream.mkString(Stream.decodeText(handle.stdout)),
|
||||
stderr: Stream.mkString(Stream.decodeText(handle.stderr))
|
||||
}, { concurrency: "unbounded" })
|
||||
})
|
||||
|
||||
describe("openapigen CLI", () => {
|
||||
it.effect("documents --format values and default in --help", () =>
|
||||
Effect.gen(function*() {
|
||||
const result = yield* runCli(["--help"])
|
||||
|
||||
assert.isTrue(Exit.isSuccess(result.exit))
|
||||
assert.include(result.stdout, "--format")
|
||||
assert.include(result.stdout, "httpclient")
|
||||
assert.include(result.stdout, "httpclient-type-only")
|
||||
assert.include(result.stdout, "httpapi")
|
||||
assert.include(result.stdout, "default: httpclient")
|
||||
assert.strictEqual(result.stderr, "")
|
||||
}))
|
||||
|
||||
it.effect("routes --format values and defaults to httpclient", () =>
|
||||
Effect.gen(function*() {
|
||||
const spec = fixturePath("cli-basic-spec.json")
|
||||
|
||||
const defaultResult = yield* runCli(["--spec", spec, "--name", "CliClient"])
|
||||
const httpclientResult = yield* runCli([
|
||||
"--spec",
|
||||
spec,
|
||||
"--name",
|
||||
"CliClient",
|
||||
"--format",
|
||||
"httpclient"
|
||||
])
|
||||
const typeOnlyResult = yield* runCli([
|
||||
"--spec",
|
||||
spec,
|
||||
"--name",
|
||||
"CliClient",
|
||||
"--format",
|
||||
"httpclient-type-only"
|
||||
])
|
||||
const httpapiResult = yield* runCli([
|
||||
"--spec",
|
||||
spec,
|
||||
"--name",
|
||||
"CliClient",
|
||||
"--format",
|
||||
"httpapi"
|
||||
])
|
||||
|
||||
assert.isTrue(Exit.isSuccess(defaultResult.exit))
|
||||
assert.isTrue(Exit.isSuccess(httpclientResult.exit))
|
||||
assert.isTrue(Exit.isSuccess(typeOnlyResult.exit))
|
||||
assert.isTrue(Exit.isSuccess(httpapiResult.exit))
|
||||
|
||||
assert.strictEqual(defaultResult.stderr, "")
|
||||
assert.strictEqual(httpclientResult.stderr, "")
|
||||
assert.strictEqual(typeOnlyResult.stderr, "")
|
||||
assert.strictEqual(httpapiResult.stderr, "")
|
||||
|
||||
assert.strictEqual(defaultResult.stdout, httpclientResult.stdout)
|
||||
assert.include(httpclientResult.stdout, "import * as Schema from \"effect/Schema\"")
|
||||
assert.notInclude(typeOnlyResult.stdout, "import * as Schema from \"effect/Schema\"")
|
||||
assert.include(typeOnlyResult.stdout, "import type * as HttpClient from \"effect/unstable/http/HttpClient\"")
|
||||
assert.include(httpapiResult.stdout, "export class CliClient extends HttpApi.make(\"CliClient\")")
|
||||
}))
|
||||
|
||||
it.effect("rejects legacy --type-only flag", () =>
|
||||
Effect.gen(function*() {
|
||||
const spec = fixturePath("cli-basic-spec.json")
|
||||
const result = yield* runCli(["--spec", spec, "--name", "CliClient", "--type-only"])
|
||||
|
||||
assert.isTrue(Exit.isFailure(result.exit))
|
||||
assert.include(result.stdout, "USAGE")
|
||||
assert.include(result.stderr, "Unrecognized flag: --type-only")
|
||||
}))
|
||||
|
||||
it.effect("writes warnings to stderr and keeps stdout as generated source", () =>
|
||||
Effect.gen(function*() {
|
||||
const spec = fixturePath("cli-warning-spec.json")
|
||||
const result = yield* runCli(["--spec", spec, "--name", "CliClient"])
|
||||
|
||||
assert.isTrue(Exit.isSuccess(result.exit))
|
||||
assert.include(result.stdout, "export const make = (")
|
||||
assert.include(result.stderr, "WARNING [cookie-parameter-dropped]")
|
||||
assert.include(result.stderr, "cookie-parameter-dropped")
|
||||
assert.notInclude(result.stdout, "cookie-parameter-dropped")
|
||||
assert.notInclude(result.stderr, "export const make = (")
|
||||
}))
|
||||
|
||||
it.effect("separates generated source and warnings when spawned as a child process", () =>
|
||||
Effect.gen(function*() {
|
||||
const spec = fixturePath("cli-warning-spec.json")
|
||||
const result = yield* runCliProcess(["--spec", spec, "--name", "CliClient"])
|
||||
|
||||
assert.strictEqual(result.exitCode, ChildProcessSpawner.ExitCode(0))
|
||||
assert.include(result.stdout, "export const make = (")
|
||||
assert.notInclude(result.stdout, "WARNING [")
|
||||
assert.notInclude(result.stdout, "cookie-parameter-dropped")
|
||||
assert.include(
|
||||
result.stderr,
|
||||
"WARNING [cookie-parameter-dropped] GET /users/{id} (getUser): Cookie parameter \"session\" was dropped because non-security cookie parameters are not supported."
|
||||
)
|
||||
assert.notInclude(result.stderr, "export const make = (")
|
||||
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)))
|
||||
})
|
||||
@@ -0,0 +1,380 @@
|
||||
import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
|
||||
import * as NodeServices from "@effect/platform-node/NodeServices"
|
||||
import { assert, describe, it } from "@effect/vitest"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
import * as Path from "effect/Path"
|
||||
|
||||
const testLayer = NodeServices.layer
|
||||
|
||||
describe("OpenApiPatch", () => {
|
||||
describe("parsePatchInput", () => {
|
||||
describe("inline JSON", () => {
|
||||
it.effect("parses valid inline JSON patch", () =>
|
||||
Effect.gen(function*() {
|
||||
const result = yield* OpenApiPatch.parsePatchInput(
|
||||
"[{\"op\":\"add\",\"path\":\"/foo\",\"value\":\"bar\"}]"
|
||||
)
|
||||
assert.deepStrictEqual(result, [{ op: "add", path: "/foo", value: "bar" }])
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
|
||||
it.effect("parses inline JSON with multiple operations", () =>
|
||||
Effect.gen(function*() {
|
||||
const result = yield* OpenApiPatch.parsePatchInput(
|
||||
"[{\"op\":\"add\",\"path\":\"/a\",\"value\":1},{\"op\":\"remove\",\"path\":\"/b\"},{\"op\":\"replace\",\"path\":\"/c\",\"value\":true}]"
|
||||
)
|
||||
assert.strictEqual(result.length, 3)
|
||||
assert.strictEqual(result[0].op, "add")
|
||||
assert.strictEqual(result[1].op, "remove")
|
||||
assert.strictEqual(result[2].op, "replace")
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
|
||||
it.effect("fails on invalid JSON syntax", () =>
|
||||
Effect.gen(function*() {
|
||||
const exit = yield* Effect.exit(
|
||||
OpenApiPatch.parsePatchInput("[{\"op\":\"add\" \"path\":\"/foo\"}]")
|
||||
)
|
||||
assert.isTrue(Exit.isFailure(exit))
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
|
||||
it.effect("fails on unsupported operation", () =>
|
||||
Effect.gen(function*() {
|
||||
const exit = yield* Effect.exit(
|
||||
OpenApiPatch.parsePatchInput("[{\"op\":\"copy\",\"from\":\"/a\",\"path\":\"/b\"}]")
|
||||
)
|
||||
assert.isTrue(Exit.isFailure(exit))
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
|
||||
it.effect("fails on missing path field", () =>
|
||||
Effect.gen(function*() {
|
||||
const exit = yield* Effect.exit(
|
||||
OpenApiPatch.parsePatchInput("[{\"op\":\"add\",\"value\":\"test\"}]")
|
||||
)
|
||||
assert.isTrue(Exit.isFailure(exit))
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
|
||||
it.effect("fails on missing value for add operation", () =>
|
||||
Effect.gen(function*() {
|
||||
const exit = yield* Effect.exit(
|
||||
OpenApiPatch.parsePatchInput("[{\"op\":\"add\",\"path\":\"/foo\"}]")
|
||||
)
|
||||
assert.isTrue(Exit.isFailure(exit))
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
|
||||
it.effect("allows missing value for remove operation", () =>
|
||||
Effect.gen(function*() {
|
||||
const result = yield* OpenApiPatch.parsePatchInput(
|
||||
"[{\"op\":\"remove\",\"path\":\"/foo\"}]"
|
||||
)
|
||||
assert.deepStrictEqual(result, [{ op: "remove", path: "/foo" }])
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
})
|
||||
|
||||
describe("file paths", () => {
|
||||
it.effect("parses valid JSON file", () =>
|
||||
Effect.gen(function*() {
|
||||
const pathService = yield* Path.Path
|
||||
const filePath = pathService.join(
|
||||
import.meta.dirname,
|
||||
"fixtures/patches/valid-add.json"
|
||||
)
|
||||
const result = yield* OpenApiPatch.parsePatchInput(filePath)
|
||||
assert.strictEqual(result.length, 1)
|
||||
assert.strictEqual(result[0].op, "add")
|
||||
assert.strictEqual(result[0].path, "/info/x-custom")
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
|
||||
it.effect("parses valid YAML file", () =>
|
||||
Effect.gen(function*() {
|
||||
const pathService = yield* Path.Path
|
||||
const filePath = pathService.join(
|
||||
import.meta.dirname,
|
||||
"fixtures/patches/valid-patch.yaml"
|
||||
)
|
||||
const result = yield* OpenApiPatch.parsePatchInput(filePath)
|
||||
assert.strictEqual(result.length, 2)
|
||||
assert.strictEqual(result[0].op, "replace")
|
||||
assert.strictEqual(result[1].op, "add")
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
|
||||
it.effect("parses multiple operations from JSON file", () =>
|
||||
Effect.gen(function*() {
|
||||
const pathService = yield* Path.Path
|
||||
const filePath = pathService.join(
|
||||
import.meta.dirname,
|
||||
"fixtures/patches/valid-multiple.json"
|
||||
)
|
||||
const result = yield* OpenApiPatch.parsePatchInput(filePath)
|
||||
assert.strictEqual(result.length, 3)
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
|
||||
it.effect("fails on file with unsupported operation", () =>
|
||||
Effect.gen(function*() {
|
||||
const pathService = yield* Path.Path
|
||||
const filePath = pathService.join(
|
||||
import.meta.dirname,
|
||||
"fixtures/patches/invalid-op.json"
|
||||
)
|
||||
const exit = yield* Effect.exit(OpenApiPatch.parsePatchInput(filePath))
|
||||
assert.isTrue(Exit.isFailure(exit))
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
|
||||
it.effect("fails on file with missing path", () =>
|
||||
Effect.gen(function*() {
|
||||
const pathService = yield* Path.Path
|
||||
const filePath = pathService.join(
|
||||
import.meta.dirname,
|
||||
"fixtures/patches/missing-path.json"
|
||||
)
|
||||
const exit = yield* Effect.exit(OpenApiPatch.parsePatchInput(filePath))
|
||||
assert.isTrue(Exit.isFailure(exit))
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
|
||||
it.effect("falls back to inline JSON when file does not exist", () =>
|
||||
Effect.gen(function*() {
|
||||
const result = yield* OpenApiPatch.parsePatchInput(
|
||||
"[{\"op\":\"add\",\"path\":\"/x\",\"value\":1}]"
|
||||
)
|
||||
assert.deepStrictEqual(result, [{ op: "add", path: "/x", value: 1 }])
|
||||
}).pipe(Effect.provide(testLayer)))
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyPatches", () => {
|
||||
it.effect("applies single add patch", () =>
|
||||
Effect.gen(function*() {
|
||||
const document = { info: { title: "Test" } }
|
||||
const patches = [{
|
||||
source: "test",
|
||||
patch: [{ op: "add" as const, path: "/info/version", value: "1.0.0" }]
|
||||
}]
|
||||
const result = yield* OpenApiPatch.applyPatches(patches, document)
|
||||
assert.deepStrictEqual(result, { info: { title: "Test", version: "1.0.0" } })
|
||||
}))
|
||||
|
||||
it.effect("applies single remove patch", () =>
|
||||
Effect.gen(function*() {
|
||||
const document = { info: { title: "Test", deprecated: true } }
|
||||
const patches = [{
|
||||
source: "test",
|
||||
patch: [{ op: "remove" as const, path: "/info/deprecated" }]
|
||||
}]
|
||||
const result = yield* OpenApiPatch.applyPatches(patches, document)
|
||||
assert.deepStrictEqual(result, { info: { title: "Test" } })
|
||||
}))
|
||||
|
||||
it.effect("applies single replace patch", () =>
|
||||
Effect.gen(function*() {
|
||||
const document = { info: { title: "Old Title" } }
|
||||
const patches = [{
|
||||
source: "test",
|
||||
patch: [{ op: "replace" as const, path: "/info/title", value: "New Title" }]
|
||||
}]
|
||||
const result = yield* OpenApiPatch.applyPatches(patches, document)
|
||||
assert.deepStrictEqual(result, { info: { title: "New Title" } })
|
||||
}))
|
||||
|
||||
it.effect("applies multiple patches in sequence", () =>
|
||||
Effect.gen(function*() {
|
||||
const document = { info: { title: "Original", version: "0.0.1" } }
|
||||
const patches = [
|
||||
{
|
||||
source: "patch1",
|
||||
patch: [{ op: "replace" as const, path: "/info/title", value: "Step 1" }]
|
||||
},
|
||||
{
|
||||
source: "patch2",
|
||||
patch: [{ op: "replace" as const, path: "/info/version", value: "1.0.0" }]
|
||||
},
|
||||
{
|
||||
source: "patch3",
|
||||
patch: [{ op: "add" as const, path: "/info/x-patched", value: true }]
|
||||
}
|
||||
]
|
||||
const result = yield* OpenApiPatch.applyPatches(patches, document)
|
||||
assert.deepStrictEqual(result, {
|
||||
info: { title: "Step 1", version: "1.0.0", "x-patched": true }
|
||||
})
|
||||
}))
|
||||
|
||||
it.effect("applies multiple operations within a single patch", () =>
|
||||
Effect.gen(function*() {
|
||||
const document = { info: { title: "Test" }, paths: {} }
|
||||
const patches = [{
|
||||
source: "test",
|
||||
patch: [
|
||||
{ op: "replace" as const, path: "/info/title", value: "Updated" },
|
||||
{ op: "add" as const, path: "/info/version", value: "2.0.0" }
|
||||
]
|
||||
}]
|
||||
const result = yield* OpenApiPatch.applyPatches(patches, document)
|
||||
assert.deepStrictEqual(result, {
|
||||
info: { title: "Updated", version: "2.0.0" },
|
||||
paths: {}
|
||||
})
|
||||
}))
|
||||
|
||||
it.effect("fails when path does not exist for replace", () =>
|
||||
Effect.gen(function*() {
|
||||
const document = { info: { title: "Test" } }
|
||||
const patches = [{
|
||||
source: "test",
|
||||
patch: [{ op: "replace" as const, path: "/info/nonexistent", value: "x" }]
|
||||
}]
|
||||
const exit = yield* Effect.exit(OpenApiPatch.applyPatches(patches, document))
|
||||
assert.isTrue(Exit.isFailure(exit))
|
||||
}))
|
||||
|
||||
it.effect("fails when path does not exist for remove", () =>
|
||||
Effect.gen(function*() {
|
||||
const document = { info: { title: "Test" } }
|
||||
const patches = [{
|
||||
source: "test",
|
||||
patch: [{ op: "remove" as const, path: "/info/nonexistent" }]
|
||||
}]
|
||||
const exit = yield* Effect.exit(OpenApiPatch.applyPatches(patches, document))
|
||||
assert.isTrue(Exit.isFailure(exit))
|
||||
}))
|
||||
|
||||
it.effect("accumulates multiple errors", () =>
|
||||
Effect.gen(function*() {
|
||||
const document = { info: { title: "Test" } }
|
||||
const patches = [{
|
||||
source: "test.json",
|
||||
patch: [
|
||||
{ op: "replace" as const, path: "/info/nonexistent1", value: "x" },
|
||||
{ op: "remove" as const, path: "/info/nonexistent2" },
|
||||
{ op: "replace" as const, path: "/info/nonexistent3", value: "y" }
|
||||
]
|
||||
}]
|
||||
const exit = yield* Effect.exit(OpenApiPatch.applyPatches(patches, document))
|
||||
assert.isTrue(Exit.isFailure(exit))
|
||||
if (Exit.isFailure(exit)) {
|
||||
const failure = exit.cause.reasons[0]
|
||||
if (failure._tag === "Fail") {
|
||||
assert.strictEqual(failure.error._tag, "JsonPatchAggregateError")
|
||||
assert.strictEqual(failure.error.errors.length, 3)
|
||||
assert.include(failure.error.message, "3 patch operations failed")
|
||||
assert.include(failure.error.message, "/info/nonexistent1")
|
||||
assert.include(failure.error.message, "/info/nonexistent2")
|
||||
assert.include(failure.error.message, "/info/nonexistent3")
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
it.effect("accumulates errors across multiple patches", () =>
|
||||
Effect.gen(function*() {
|
||||
const document = { info: { title: "Test" } }
|
||||
const patches = [
|
||||
{
|
||||
source: "patch1.json",
|
||||
patch: [{ op: "remove" as const, path: "/info/missing1" }]
|
||||
},
|
||||
{
|
||||
source: "patch2.json",
|
||||
patch: [{ op: "remove" as const, path: "/info/missing2" }]
|
||||
}
|
||||
]
|
||||
const exit = yield* Effect.exit(OpenApiPatch.applyPatches(patches, document))
|
||||
assert.isTrue(Exit.isFailure(exit))
|
||||
if (Exit.isFailure(exit)) {
|
||||
const failure = exit.cause.reasons[0]
|
||||
if (failure._tag === "Fail") {
|
||||
assert.strictEqual(failure.error.errors.length, 2)
|
||||
assert.include(failure.error.message, "patch1.json")
|
||||
assert.include(failure.error.message, "patch2.json")
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
it.effect("preserves unmodified parts of document", () =>
|
||||
Effect.gen(function*() {
|
||||
const document = {
|
||||
info: { title: "Test", description: "Unchanged" },
|
||||
paths: { "/users": { get: {} } },
|
||||
components: { schemas: {} }
|
||||
}
|
||||
const patches = [{
|
||||
source: "test",
|
||||
patch: [{ op: "replace" as const, path: "/info/title", value: "Changed" }]
|
||||
}]
|
||||
const result = yield* OpenApiPatch.applyPatches(patches, document)
|
||||
assert.strictEqual((result as { info: { description: string } }).info.description, "Unchanged")
|
||||
assert.deepStrictEqual((result as { paths: object }).paths, { "/users": { get: {} } })
|
||||
assert.deepStrictEqual((result as { components: object }).components, { schemas: {} })
|
||||
}))
|
||||
|
||||
it.effect("returns original document when no patches provided", () =>
|
||||
Effect.gen(function*() {
|
||||
const document = { info: { title: "Test" } }
|
||||
const result = yield* OpenApiPatch.applyPatches([], document)
|
||||
assert.deepStrictEqual(result, document)
|
||||
}))
|
||||
})
|
||||
|
||||
describe("error messages", () => {
|
||||
it.effect("JsonPatchParseError has descriptive message", () =>
|
||||
Effect.gen(function*() {
|
||||
const error = new OpenApiPatch.JsonPatchParseError({
|
||||
source: "./fix.json",
|
||||
reason: "Unexpected token"
|
||||
})
|
||||
assert.strictEqual(
|
||||
error.message,
|
||||
"Failed to parse patch from ./fix.json: Unexpected token"
|
||||
)
|
||||
}))
|
||||
|
||||
it.effect("JsonPatchValidationError has descriptive message", () =>
|
||||
Effect.gen(function*() {
|
||||
const error = new OpenApiPatch.JsonPatchValidationError({
|
||||
source: "inline",
|
||||
reason: "Missing 'path' field"
|
||||
})
|
||||
assert.strictEqual(
|
||||
error.message,
|
||||
"Invalid JSON Patch from inline: Missing 'path' field"
|
||||
)
|
||||
}))
|
||||
|
||||
it.effect("JsonPatchApplicationError has descriptive message", () =>
|
||||
Effect.gen(function*() {
|
||||
const error = new OpenApiPatch.JsonPatchApplicationError({
|
||||
source: "./fix.json",
|
||||
operationIndex: 2,
|
||||
operation: "remove",
|
||||
path: "/info/x",
|
||||
reason: "Property does not exist"
|
||||
})
|
||||
assert.strictEqual(
|
||||
error.message,
|
||||
"Failed to apply patch from ./fix.json: operation 2 (remove at /info/x): Property does not exist"
|
||||
)
|
||||
}))
|
||||
|
||||
it.effect("JsonPatchAggregateError has descriptive message", () =>
|
||||
Effect.gen(function*() {
|
||||
const error = new OpenApiPatch.JsonPatchAggregateError({
|
||||
errors: [
|
||||
new OpenApiPatch.JsonPatchApplicationError({
|
||||
source: "./fix.json",
|
||||
operationIndex: 0,
|
||||
operation: "replace",
|
||||
path: "/info/x",
|
||||
reason: "Property does not exist"
|
||||
}),
|
||||
new OpenApiPatch.JsonPatchApplicationError({
|
||||
source: "./other.json",
|
||||
operationIndex: 1,
|
||||
operation: "remove",
|
||||
path: "/paths/~1users",
|
||||
reason: "Path not found"
|
||||
})
|
||||
]
|
||||
})
|
||||
assert.include(error.message, "2 patch operations failed")
|
||||
assert.include(error.message, "1. [./fix.json] op 0 (replace at /info/x)")
|
||||
assert.include(error.message, "2. [./other.json] op 1 (remove at /paths/~1users)")
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as Utils from "@effect/openapi-generator/Utils"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
describe("Utils", () => {
|
||||
describe("camelize", () => {
|
||||
it("removes hyphens and capitalizes following letters", () => {
|
||||
expect(Utils.camelize("my-operation-id")).toBe("myOperationId")
|
||||
})
|
||||
|
||||
it("removes slashes and capitalizes following letters", () => {
|
||||
expect(Utils.camelize("my/operation/id")).toBe("myOperationId")
|
||||
})
|
||||
|
||||
it("handles numbers", () => {
|
||||
expect(Utils.camelize("operation-2")).toBe("operation2")
|
||||
})
|
||||
|
||||
it("removes leading numbers", () => {
|
||||
expect(Utils.camelize("2operation")).toBe("operation")
|
||||
})
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(Utils.camelize("")).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("identifier", () => {
|
||||
it("capitalizes camelized string", () => {
|
||||
expect(Utils.identifier("my-operation")).toBe("MyOperation")
|
||||
expect(Utils.identifier("operation-2")).toBe("Operation2")
|
||||
})
|
||||
})
|
||||
})
|
||||
35
repos/effect/packages/tools/openapi-generator/test/fixtures/cli-basic-spec.json
vendored
Normal file
35
repos/effect/packages/tools/openapi-generator/test/fixtures/cli-basic-spec.json
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "CLI Basic API",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/users": {
|
||||
"get": {
|
||||
"operationId": "listUsers",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List users",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {},
|
||||
"securitySchemes": {}
|
||||
},
|
||||
"security": [],
|
||||
"tags": []
|
||||
}
|
||||
57
repos/effect/packages/tools/openapi-generator/test/fixtures/cli-warning-spec.json
vendored
Normal file
57
repos/effect/packages/tools/openapi-generator/test/fixtures/cli-warning-spec.json
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "CLI Warning API",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"operationId": "getUser",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "session",
|
||||
"in": "cookie",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {},
|
||||
"securitySchemes": {}
|
||||
},
|
||||
"security": [],
|
||||
"tags": []
|
||||
}
|
||||
7
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/invalid-op.json
vendored
Normal file
7
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/invalid-op.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"op": "copy",
|
||||
"from": "/info/title",
|
||||
"path": "/info/x-title"
|
||||
}
|
||||
]
|
||||
6
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/missing-path.json
vendored
Normal file
6
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/missing-path.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"op": "add",
|
||||
"value": "test"
|
||||
}
|
||||
]
|
||||
6
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/missing-value.json
vendored
Normal file
6
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/missing-value.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/info/x-missing"
|
||||
}
|
||||
]
|
||||
8
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/valid-add.json
vendored
Normal file
8
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/valid-add.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/info/x-custom",
|
||||
"value": "custom-value",
|
||||
"description": "Add custom extension"
|
||||
}
|
||||
]
|
||||
17
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/valid-multiple.json
vendored
Normal file
17
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/valid-multiple.json
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/info/title",
|
||||
"value": "My Custom API"
|
||||
},
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/info/x-generator",
|
||||
"value": "effect-openapi"
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/info/version",
|
||||
"value": "2.0.0"
|
||||
}
|
||||
]
|
||||
6
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/valid-patch.yaml
vendored
Normal file
6
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/valid-patch.yaml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
- op: replace
|
||||
path: /info/title
|
||||
value: YAML Patched Title
|
||||
- op: add
|
||||
path: /info/x-yaml-patch
|
||||
value: true
|
||||
6
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/valid-remove.json
vendored
Normal file
6
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/valid-remove.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/paths/~1deprecated"
|
||||
}
|
||||
]
|
||||
7
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/valid-replace.json
vendored
Normal file
7
repos/effect/packages/tools/openapi-generator/test/fixtures/patches/valid-replace.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/info/title",
|
||||
"value": "Updated API Title"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user