From ab732393d6da61d56c011ea8db83a01bd65f7271 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Mon, 31 Aug 2026 14:16:45 +0000 Subject: [PATCH] fix(compiler): allow serializing duration and regex in Json Structs with duration or regex fields can now be converted to/from Json. Duration values serialize as a string of integer milliseconds, and regex values as a JavaScript RegExp string (e.g. "/p[a-z]+ch/"). Fixes #7038 --- packages/@winglang/sdk/src/std/json_schema.ts | 66 ++++++++++++++- .../sdk/test/std/json_schema.test.ts | 84 +++++++++++++++++++ .../wingc/src/json_schema_generator.rs | 15 ++++ packages/@winglang/wingc/src/type_check.rs | 1 + tests/valid/struct_json_serialization.test.w | 62 ++++++++++++++ 5 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 packages/@winglang/sdk/test/std/json_schema.test.ts create mode 100644 tests/valid/struct_json_serialization.test.w diff --git a/packages/@winglang/sdk/src/std/json_schema.ts b/packages/@winglang/sdk/src/std/json_schema.ts index 376fffe0315..44162ff3dd0 100644 --- a/packages/@winglang/sdk/src/std/json_schema.ts +++ b/packages/@winglang/sdk/src/std/json_schema.ts @@ -1,5 +1,7 @@ import Ajv from "ajv"; import { Json, JsonValidationOptions } from "./json"; +import { Duration } from "./duration"; +import { Regex } from "./regex"; import { InflightClient } from "../core"; import { extractFieldsFromSchema, @@ -36,6 +38,10 @@ export class JsonSchema { constructor(schema: Json) { this._rawSchema = schema; this.validator = new Ajv({ allErrors: true, allowUnionTypes: true }); + // register the custom formats used to mark `duration`/`regex` struct fields + // in the schema (their string representation is always considered valid) + this.validator.addFormat("duration", true); + this.validator.addFormat("regex", true); } /** @@ -78,8 +84,12 @@ export class JsonSchema { // Filter rawParameters based on the schema const filteredParameters = filterParametersBySchema(fields, obj); - // Remove all `null` values (recursively) - const cleanedParameters = removeNullValues(filteredParameters); + // Remove all `null` values (recursively), then convert any `duration`/`regex` + // values from their string representation into their corresponding objects. + const cleanedParameters = convertValuesBySchema( + removeNullValues(filteredParameters), + this._rawSchema, + ); return cleanedParameters; } @@ -120,3 +130,55 @@ function removeNullValues(obj: any): any { return obj; } + +/** + * Walks a value alongside the corresponding JSON schema and converts any + * `duration` or `regex` fields (represented as strings) into their winged + * counterpart objects so that structs produced by `fromJson` hold real + * `Duration`/`Regex` instances rather than raw strings. + */ +function convertValuesBySchema(value: any, schema: any): any { + if (value === undefined || value === null) { + return value; + } + + const format = schema?.format; + if (format === "duration") { + return Duration.fromMilliseconds(parseInt(value, 10)); + } + if (format === "regex") { + // strip the leading "/" and trailing "/" (and any trailing flags) of a + // JavaScript RegExp string representation, e.g. "/p[a-z]+ch/" -> "p[a-z]+ch" + const match = /^\/(.*)\/([a-z]*)$/.exec(String(value)); + const pattern = match ? match[1] : String(value); + return Regex.compile(pattern); + } + + if (Array.isArray(value)) { + const items = schema?.items; + return value.map((item) => convertValuesBySchema(item, items)); + } + + if (Array.isArray(schema?.oneOf)) { + // oneOf is used for optional fields: pick the branch that isn't `null` + const nonNull = schema.oneOf.find((s: any) => s.type !== "null"); + return convertValuesBySchema(value, nonNull); + } + + if (typeof value === "object" && value !== null) { + const properties = schema?.properties; + const patternProperties = schema?.patternProperties; + const result: any = {}; + for (const [key, val] of Object.entries(value)) { + const fieldSchema = + properties?.[key] ?? + (patternProperties + ? Object.values(patternProperties).find((_) => true) + : undefined); + result[key] = convertValuesBySchema(val, fieldSchema); + } + return result; + } + + return value; +} diff --git a/packages/@winglang/sdk/test/std/json_schema.test.ts b/packages/@winglang/sdk/test/std/json_schema.test.ts new file mode 100644 index 00000000000..e47bacf0a87 --- /dev/null +++ b/packages/@winglang/sdk/test/std/json_schema.test.ts @@ -0,0 +1,84 @@ +import { test, expect } from "vitest"; +import { JsonSchema } from "../../src/std/json_schema"; + +test("fromJson converts top-level duration and regex fields", () => { + const schema = new JsonSchema({ + $id: "/MyStruct", + type: "object", + properties: { + field1: { type: "string", format: "duration" }, + field2: { type: "string" }, + field3: { type: "string", format: "regex" }, + }, + required: ["field1", "field2", "field3"], + }); + + const result: any = schema._fromJson({ + field1: "6000", + field2: "hi", + field3: "/p[a-z]+ch/", + }); + + expect(result.field1.milliseconds).toBe(6000); + expect(result.field2).toBe("hi"); + expect(result.field3.test("punch")).toBe(true); + expect(result.field3.test("reach")).toBe(false); +}); + +test("fromJson converts duration/regex fields nested in structs, arrays, maps, and optionals", () => { + const schema = new JsonSchema({ + $id: "/MyStruct", + type: "object", + properties: { + inner: { + type: "object", + properties: { + d: { type: "string", format: "duration" }, + }, + required: ["d"], + }, + arr: { + type: "array", + items: { type: "string", format: "regex" }, + }, + map: { + type: "object", + patternProperties: { ".*": { type: "string", format: "duration" } }, + }, + opt: { oneOf: [{ type: "null" }, { type: "string", format: "regex" }] }, + }, + required: ["inner", "arr", "map"], + }); + + const result: any = schema._fromJson({ + inner: { d: "1000" }, + arr: ["/ab+c/", "/[0-9]+/"], + map: { a: "5000", b: "2000" }, + opt: "/x+y/", + }); + + expect(result.inner.d.milliseconds).toBe(1000); + expect(result.arr[0].test("abbbc")).toBe(true); + expect(result.arr[1].test("123")).toBe(true); + expect(result.map.a.milliseconds).toBe(5000); + expect(result.map.b.milliseconds).toBe(2000); + expect(result.opt.test("xxy")).toBe(true); +}); + +test("fromJson leaves primitive fields untouched", () => { + const schema = new JsonSchema({ + $id: "/MyStruct", + type: "object", + properties: { + s: { type: "string" }, + n: { type: "number" }, + b: { type: "boolean" }, + }, + required: ["s", "n", "b"], + }); + + const result: any = schema._fromJson({ s: "hi", n: 5, b: true }); + expect(result.s).toBe("hi"); + expect(result.n).toBe(5); + expect(result.b).toBe(true); +}); diff --git a/packages/@winglang/wingc/src/json_schema_generator.rs b/packages/@winglang/wingc/src/json_schema_generator.rs index 452e21309f9..b87afc3571f 100644 --- a/packages/@winglang/wingc/src/json_schema_generator.rs +++ b/packages/@winglang/wingc/src/json_schema_generator.rs @@ -50,6 +50,21 @@ impl JsonSchemaGenerator { None => format!("{{type:\"{}\"}}", jsified_type), } } + Type::Duration | Type::Regex => { + let format_name = if matches!(**typ, Type::Duration) { + "duration" + } else { + "regex" + }; + match docs { + Some(docs) => format!( + "{{type:\"string\",format:\"{}\",description:\"{}\"}}", + format_name, + docs.to_escaped_string() + ), + None => format!("{{type:\"string\",format:\"{}\"}}", format_name), + } + } Type::Struct(ref s) => { let mut code = CodeMaker::default(); code.append("{"); diff --git a/packages/@winglang/wingc/src/type_check.rs b/packages/@winglang/wingc/src/type_check.rs index 0679fbc58b9..e746c4cea95 100644 --- a/packages/@winglang/wingc/src/type_check.rs +++ b/packages/@winglang/wingc/src/type_check.rs @@ -1420,6 +1420,7 @@ impl TypeRef { true } Type::Enum(_) => true, + Type::Duration | Type::Regex => true, Type::Optional(t) | Type::Array(t) | Type::Set(t) | Type::Map(t) => t.has_json_representation(), _ => self.is_json_legal_value(), } diff --git a/tests/valid/struct_json_serialization.test.w b/tests/valid/struct_json_serialization.test.w new file mode 100644 index 00000000000..b53f09daf48 --- /dev/null +++ b/tests/valid/struct_json_serialization.test.w @@ -0,0 +1,62 @@ +// Duration and regex fields can be serialized to/from Json. +// Each duration is serialized as a string with an integer number of milliseconds, +// and each regex as a JavaScript RegExp string (e.g. "/p[a-z]+ch/"). +struct MyStruct { + field1: duration; + field2: str; + field3: regex; +} + +let fromJson = MyStruct.fromJson({ + field1: "6000", + field2: "hi", + field3: "/p[a-z]+ch/", +}); + +assert(fromJson.field1.milliseconds == 6000); +assert(fromJson.field2 == "hi"); +assert(fromJson.field3.test("punch")); +assert(!fromJson.field3.test("reach")); + +// Nested structs, arrays, maps and optional fields also round-trip. +struct Inner { + d: duration; +} + +struct Wrapper { + inner: Inner; + arr: Array; + map: Map; + opt: regex?; +} + +let wrapper = Wrapper.fromJson({ + inner: { d: "1000" }, + arr: ["/ab+c/", "/[0-9]+/"], + map: { a: "5000" }, + opt: "/x+y/", +}); + +assert(wrapper.inner.d.milliseconds == 1000); +assert(wrapper.arr.at(0).test("abbbc")); +assert(wrapper.arr.at(1).test("123")); +assert(wrapper.map.get("a").milliseconds == 5000); + +if let opt = wrapper.opt { + assert(opt.test("xxy")); +} else { + assert(false); +} + +// fromJson on a value without a regex optional field yields nil for that field. +let wrapper2 = Wrapper.fromJson({ + inner: { d: "2000" }, + arr: [], + map: {}, +}); + +assert(wrapper2.inner.d.milliseconds == 2000); + +if let opt = wrapper2.opt { + assert(false); +}