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
66 changes: 64 additions & 2 deletions packages/@winglang/sdk/src/std/json_schema.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
84 changes: 84 additions & 0 deletions packages/@winglang/sdk/test/std/json_schema.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
15 changes: 15 additions & 0 deletions packages/@winglang/wingc/src/json_schema_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("{");
Expand Down
1 change: 1 addition & 0 deletions packages/@winglang/wingc/src/type_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
Expand Down
62 changes: 62 additions & 0 deletions tests/valid/struct_json_serialization.test.w
Original file line number Diff line number Diff line change
@@ -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<regex>;
map: Map<duration>;
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);
}
Loading