Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1800,6 +1800,7 @@ const isCollectionElementBindableAst = (ast: SchemaAST.AST): boolean => {
case "BigInt":
case "Boolean":
case "TemplateLiteral":
case "Enum":
return true
case "Literal": {
const literal = (ast as SchemaAST.Literal).literal
Expand Down Expand Up @@ -1845,6 +1846,8 @@ const isStringBindableAst = (ast: SchemaAST.AST): boolean => {
// string/number/boolean/bigint literals are all valid in URL
// contexts; null literal also serialisable.
return true
case "Enum":
return true
case "TemplateLiteral":
return true
case "UniqueSymbol":
Expand Down
37 changes: 37 additions & 0 deletions src/WitCodec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,16 @@ const encodedShapeOf = (a: SchemaAST.AST): EncodedShape => {
matches: (v) => typeof v === "object" && v !== null && !Array.isArray(v),
}
}
case "Enum": {
const values = (a as SchemaAST.Enum).enums.map(([, v]) => v)
const valuesSet = new Set<unknown>(values)
const allStrings = values.every((v) => typeof v === "string")
const allNumbers = values.every((v) => typeof v === "number")
return {
tag: allStrings ? "string" : allNumbers ? "number" : "unknown",
matches: (v) => valuesSet.has(v),
}
}
default:
return { tag: "unknown", matches: () => true }
}
Expand Down Expand Up @@ -610,6 +620,33 @@ const walk = (
return yield* declarationNode(a)
}

case "Enum": {
const astEnum = a as SchemaAST.Enum
if (astEnum.enums.length === 0) {
return yield* unsupported("empty enum")
}
const allStrings = astEnum.enums.every(([, v]) => typeof v === "string")
if (allStrings) {
const literals = astEnum.enums.map(([, v]) => v as string)
return {
node: { tag: "enum-type", val: literals },
pair: {
toTree: (s: string) => ({ tag: "enum-value", val: literals.indexOf(s) }),
fromTree: (t) => literals[(t as { val: number }).val]!,
},
}
}
const names = astEnum.enums.map(([name]) => name)
const values = astEnum.enums.map(([, v]) => v)
return {
node: { tag: "enum-type", val: names },
pair: {
toTree: (v: unknown) => ({ tag: "enum-value", val: values.indexOf(v as any) }),
fromTree: (t) => values[(t as { val: number }).val]!,
},
}
}

default:
return yield* unsupported(`unsupported AST node: ${a._tag}`)
}
Expand Down
200 changes: 200 additions & 0 deletions test/wit-codec-enum.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { describe, expect, it } from "@effect/vitest"
import { Effect, Schema } from "effect"
import { defineAgent, Http, method } from "../src/index.js"
import { toWitCodec } from "../src/WitCodec.js"

enum UserConnectionType {
Friend = "Friend",
Follower = "Follower",
Following = "Following",
}

enum LikeType {
Like = "like",
Insightful = "insightful",
Love = "love",
Dislike = "dislike",
}

enum Status {
Pending = 0,
Active = 1,
Completed = 2,
}

enum HttpCode {
Ok = 200,
NotFound = 404,
}

describe("toWitCodec with Schema.Enum", () => {
it.effect("round-trips string enum where keys equal values", () =>
Effect.gen(function* () {
const SchemaEnum = Schema.Enum(UserConnectionType)
const wc = yield* toWitCodec(SchemaEnum)

expect(wc.witType.nodes[0]?.type).toEqual({
tag: "enum-type",
val: ["Friend", "Follower", "Following"],
})

for (const val of [
UserConnectionType.Friend,
UserConnectionType.Follower,
UserConnectionType.Following,
]) {
const wv = yield* Schema.encodeEffect(wc.codec)(val)
const back = yield* Schema.decodeEffect(wc.codec)(wv)
expect(back).toBe(val)
}
}),
)

it.effect("round-trips string enum where keys differ from values", () =>
Effect.gen(function* () {
const SchemaEnum = Schema.Enum(LikeType)
const wc = yield* toWitCodec(SchemaEnum)

expect(wc.witType.nodes[0]?.type).toEqual({
tag: "enum-type",
val: ["like", "insightful", "love", "dislike"],
})

for (const val of [LikeType.Like, LikeType.Insightful, LikeType.Love, LikeType.Dislike]) {
const wv = yield* Schema.encodeEffect(wc.codec)(val)
const back = yield* Schema.decodeEffect(wc.codec)(wv)
expect(back).toBe(val)
}
}),
)

it.effect("round-trips numeric enum", () =>
Effect.gen(function* () {
const SchemaEnum = Schema.Enum(Status)
const wc = yield* toWitCodec(SchemaEnum)

expect(wc.witType.nodes[0]?.type).toEqual({
tag: "enum-type",
val: ["Pending", "Active", "Completed"],
})

for (const val of [Status.Pending, Status.Active, Status.Completed]) {
const wv = yield* Schema.encodeEffect(wc.codec)(val)
const back = yield* Schema.decodeEffect(wc.codec)(wv)
expect(back).toBe(val)
}
}),
)

it.effect("round-trips non-sequential numeric enum", () =>
Effect.gen(function* () {
const SchemaEnum = Schema.Enum(HttpCode)
const wc = yield* toWitCodec(SchemaEnum)

expect(wc.witType.nodes[0]?.type).toEqual({
tag: "enum-type",
val: ["Ok", "NotFound"],
})

for (const val of [HttpCode.Ok, HttpCode.NotFound]) {
const wv = yield* Schema.encodeEffect(wc.codec)(val)
const back = yield* Schema.decodeEffect(wc.codec)(wv)
expect(back).toBe(val)
}
}),
)

it.effect("round-trips Schema.Struct containing Schema.Enum", () =>
Effect.gen(function* () {
const Payload = Schema.Struct({
conn: Schema.Enum(UserConnectionType),
status: Schema.Enum(Status),
message: Schema.String,
})
const wc = yield* toWitCodec(Payload)

const val = {
conn: UserConnectionType.Follower,
status: Status.Active,
message: "hello",
}
const wv = yield* Schema.encodeEffect(wc.codec)(val)
const back = yield* Schema.decodeEffect(wc.codec)(wv)
expect(back).toEqual(val)
}),
)

it.effect("round-trips Schema.Array of Schema.Enum", () =>
Effect.gen(function* () {
const List = Schema.Array(Schema.Enum(LikeType))
const wc = yield* toWitCodec(List)

const val = [LikeType.Like, LikeType.Love, LikeType.Like]
const wv = yield* Schema.encodeEffect(wc.codec)(val)
const back = yield* Schema.decodeEffect(wc.codec)(wv)
expect(back).toEqual(val)
}),
)

it.effect("round-trips Schema.NullOr(Schema.Enum)", () =>
Effect.gen(function* () {
const Nullable = Schema.NullOr(Schema.Enum(UserConnectionType))
const wc = yield* toWitCodec(Nullable)

expect(wc.witType.nodes[0]?.type.tag).toBe("option-type")

const val1 = UserConnectionType.Friend
const wv1 = yield* Schema.encodeEffect(wc.codec)(val1)
const back1 = yield* Schema.decodeEffect(wc.codec)(wv1)
expect(back1).toBe(val1)

const val2 = null
const wv2 = yield* Schema.encodeEffect(wc.codec)(val2)
const back2 = yield* Schema.decodeEffect(wc.codec)(wv2)
expect(back2).toBeNull()
}),
)

it.effect("round-trips Schema.Union containing Schema.Enum and primitive", () =>
Effect.gen(function* () {
const UnionSchema = Schema.Union([Schema.Enum(UserConnectionType), Schema.Number])
const wc = yield* toWitCodec(UnionSchema)

expect(wc.witType.nodes[0]?.type.tag).toBe("variant-type")

const val1 = UserConnectionType.Following
const wv1 = yield* Schema.encodeEffect(wc.codec)(val1)
const back1 = yield* Schema.decodeEffect(wc.codec)(wv1)
expect(back1).toBe(val1)

const val2 = 42
const wv2 = yield* Schema.encodeEffect(wc.codec)(val2)
const back2 = yield* Schema.decodeEffect(wc.codec)(wv2)
expect(back2).toBe(val2)
}),
)

it("works in defineAgent with HTTP endpoint bindings", () => {
const AgentDef = defineAgent({
name: "EnumAgent",
constructorParams: { name: Schema.String },
http: Http.mount("/enum-agent/{name}"),
methods: {
getConnection: method({
params: { type: Schema.Enum(UserConnectionType) },
success: Schema.Enum(UserConnectionType),
http: [Http.get("/connection?type={type}")],
}),
getStatus: method({
params: { status: Schema.Enum(Status) },
success: Schema.Enum(Status),
http: [Http.get("/status/{status}")],
}),
},
})

expect(AgentDef.name).toBe("EnumAgent")
expect(AgentDef.methods.getConnection).toBeDefined()
expect(AgentDef.methods.getStatus).toBeDefined()
})
})
58 changes: 58 additions & 0 deletions test/wit-codec-union.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,62 @@ describe("Schema.Union → WIT variant", () => {
expect(b.back).toEqual({ _tag: "named", name: "x" })
}),
)

it.effect("string-literal union (Schema.Literals) emits enum-type and round-trips", () =>
Effect.gen(function* () {
const Color = Schema.Literals(["red", "green", "blue"])
const wc = yield* compile(Color)
expect(wc.witType.nodes[0]?.type).toEqual({
tag: "enum-type",
val: ["red", "green", "blue"],
})
for (const c of ["red", "green", "blue"] as const) {
const r = yield* roundtrip(Color, c)
expect(r.back).toBe(c)
expect((r.wv.nodes[0] as any).tag).toBe("enum-value")
}
}),
)

it.effect("Schema.Union of Schema.Literal strings emits enum-type and round-trips", () =>
Effect.gen(function* () {
const Fruit = Schema.Union([Schema.Literal("apple"), Schema.Literal("banana")])
const wc = yield* compile(Fruit)
expect(wc.witType.nodes[0]?.type).toEqual({
tag: "enum-type",
val: ["apple", "banana"],
})
for (const f of ["apple", "banana"] as const) {
const r = yield* roundtrip(Fruit, f)
expect(r.back).toBe(f)
expect((r.wv.nodes[0] as any).tag).toBe("enum-value")
}
}),
)

it.effect("single string literal emits prim-string-type and round-trips", () =>
Effect.gen(function* () {
const Only = Schema.Literal("constant")
const wc = yield* compile(Only)
expect(wc.witType.nodes[0]?.type).toEqual({
tag: "prim-string-type",
})
const r = yield* roundtrip(Only, "constant")
expect(r.back).toBe("constant")
expect((r.wv.nodes[0] as any).tag).toBe("prim-string")
}),
)

it.effect("single numeric literal emits prim-f64-type and round-trips", () =>
Effect.gen(function* () {
const FortyTwo = Schema.Literal(42)
const wc = yield* compile(FortyTwo)
expect(wc.witType.nodes[0]?.type).toEqual({
tag: "prim-f64-type",
})
const r = yield* roundtrip(FortyTwo, 42)
expect(r.back).toBe(42)
expect((r.wv.nodes[0] as any).tag).toBe("prim-float64")
}),
)
})