Skip to content
Closed
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
22 changes: 22 additions & 0 deletions apps/docs/data/releases.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,26 @@
[
{
"product": "sdk",
"version": "1.0.21",
"date": "2026-06-05",
"changes": [
{
"type": "~",
"description": "SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event`"
}
]
},
{
"product": "generator",
"version": "1.1.6",
"date": "2026-06-05",
"changes": [
{
"type": "~",
"description": "Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event`"
}
]
},
{
"product": "sdk",
"version": "1.0.20",
Expand Down
10 changes: 10 additions & 0 deletions apps/docs/public/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@

## ☀️ Лето 2026

### 05 июня 2026

### SDK v1.0.21

- SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event`

### Generator v1.1.6

- Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event`

### 02 июня 2026

### SDK v1.0.20
Expand Down
13 changes: 13 additions & 0 deletions apps/docs/public/updates/2026-06-05.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
> Это Markdown-версия конкретной страницы. Для контекста за её пределами (правила API, полный перечень методов, авторизация) ОБЯЗАТЕЛЬНО открой [llms.txt](https://dev.pachca.com/llms.txt) перед ответом — это сэкономит токены и предотвратит неполный ответ.

# 05 июня 2026

_05 июня 2026_

### SDK v1.0.21

- SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event`

### Generator v1.1.6

- Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event`
10 changes: 10 additions & 0 deletions apps/docs/public/updates/season/summer-2026.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

# ☀️ Лето 2026

### 05 июня 2026

### SDK v1.0.21

- SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event`

### Generator v1.1.6

- Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event`

### 02 июня 2026

### SDK v1.0.20
Expand Down
2 changes: 2 additions & 0 deletions packages/generator/src/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export interface IRUnion {
memberRefs: string[];
/** Discriminator field name detected from literal fields (e.g. "type", "entity_type") */
discriminatorField: string;
/** Optional named custom deserializer strategy from spec extension */
unionDeserializer?: string;
}

// ----- Operations -----
Expand Down
73 changes: 57 additions & 16 deletions packages/generator/src/lang/csharp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,40 +287,81 @@ function emitUnion(
.filter(Boolean) as IRModel[];

const discriminatorField = u.discriminatorField;
const useWebhookPayloadDeserializer = u.unionDeserializer === 'webhook-payload';

lines.push(`[JsonPolymorphic(TypeDiscriminatorPropertyName = "${discriminatorField}")]`);
for (const memberModel of memberModels) {
const litField = memberModel.fields.find((f) => f.type.kind === 'literal');
const litValue = litField?.type.literalValue ?? '';
lines.push(`[JsonDerivedType(typeof(${memberModel.name}), "${litValue}")]`);
if (useWebhookPayloadDeserializer) {
lines.push(`[JsonConverter(typeof(${u.name}Converter))]`);
} else {
lines.push(`[JsonPolymorphic(TypeDiscriminatorPropertyName = "${discriminatorField}")]`);
for (const memberModel of memberModels) {
const litField = memberModel.fields.find(
(f) => f.name === discriminatorField && f.type.kind === 'literal',
) ?? memberModel.fields.find((f) => f.type.kind === 'literal');
const litValue = litField?.type.literalValue ?? '';
lines.push(`[JsonDerivedType(typeof(${memberModel.name}), "${litValue}")]`);
}
}
lines.push(`public abstract class ${u.name}`);
lines.push('{');
lines.push(` [JsonPropertyName("${discriminatorField}")]`);
lines.push(` public abstract string ${snakeToPascal(discriminatorField)} { get; }`);
lines.push('}');

for (const memberModel of memberModels) {
const litField = memberModel.fields.find((f) => f.type.kind === 'literal');
const litValue = litField?.type.literalValue ?? '';
const otherFields = memberModel.fields.filter(
(f) => f.type.kind !== 'literal',
);
if (useWebhookPayloadDeserializer) {
lines.push('');
lines.push(`internal sealed class ${u.name}Converter : JsonConverter<${u.name}>`);
lines.push('{');
lines.push(` public override ${u.name} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)`);
lines.push(' {');
lines.push(' using var document = JsonDocument.ParseValue(ref reader);');
lines.push(' var root = document.RootElement;');
lines.push(` var type = root.GetProperty(${JSON.stringify(discriminatorField)}).GetString();`);
lines.push(' var eventValue = root.TryGetProperty("event", out var eventProperty) ? eventProperty.GetString() : null;');
lines.push(' var raw = root.GetRawText();');
lines.push(' return type switch');
lines.push(' {');
lines.push(' "message" when eventValue == "link_shared" => JsonSerializer.Deserialize<LinkSharedWebhookPayload>(raw, options)!,');
lines.push(' "message" => JsonSerializer.Deserialize<MessageWebhookPayload>(raw, options)!,');
for (const memberModel of memberModels.filter((m) => m.name !== 'MessageWebhookPayload' && m.name !== 'LinkSharedWebhookPayload')) {
const litField = memberModel.fields.find(
(f) => f.name === discriminatorField && f.type.kind === 'literal',
) ?? memberModel.fields.find((f) => f.type.kind === 'literal');
const litValue = litField?.type.literalValue ?? '';
lines.push(` ${JSON.stringify(litValue)} => JsonSerializer.Deserialize<${memberModel.name}>(raw, options)!,`);
}
lines.push(` _ => throw new JsonException($"Unknown ${u.name} ${discriminatorField}: {type}")`);
lines.push(' };');
lines.push(' }');
lines.push('');
lines.push(` public override void Write(Utf8JsonWriter writer, ${u.name} value, JsonSerializerOptions options)`);
lines.push(' {');
lines.push(' JsonSerializer.Serialize(writer, (object)value, value.GetType(), options);');
lines.push(' }');
lines.push('}');
}

for (const memberModel of memberModels) {
lines.push('');
lines.push(`public class ${memberModel.name} : ${u.name}`);
lines.push('{');
lines.push(` public override string ${snakeToPascal(discriminatorField)} => "${litValue}";`);
for (const f of otherFields) {
if (!memberModel.fields.some((f) => f.name === discriminatorField)) {
const litField = memberModel.fields.find((f) => f.type.kind === 'literal');
const litValue = litField?.type.literalValue ?? '';
lines.push(` public override string ${snakeToPascal(discriminatorField)} => "${litValue}";`);
}
for (const f of memberModel.fields) {
const sdkName = fieldSdkName(f);
const typeName = csType(f.type);
const isOpt = !f.required || f.nullable;
const nullSuffix = isOpt ? '?' : '';
const overrideModifier = f.name === discriminatorField ? 'override ' : '';
lines.push(` [JsonPropertyName("${f.name}")]`);
if (isOpt) {
lines.push(` public ${typeName}${nullSuffix} ${sdkName} { get; set; }`);
if (f.type.kind === 'literal') {
lines.push(` public ${overrideModifier}${typeName} ${sdkName} => ${JSON.stringify(f.type.literalValue ?? '')};`);
} else if (isOpt) {
lines.push(` public ${overrideModifier}${typeName}${nullSuffix} ${sdkName} { get; set; }`);
} else {
lines.push(` public ${typeName} ${sdkName} { get; set; } = default!;`);
lines.push(` public ${overrideModifier}${typeName} ${sdkName} { get; set; } = default!;`);
}
}
lines.push('}');
Expand Down
47 changes: 36 additions & 11 deletions packages/generator/src/lang/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,21 +238,46 @@ function emitUnion(lines: string[], u: IRUnion, models: IRModel[]): void {
lines.push(`func (u *${u.name}) UnmarshalJSON(data []byte) error {`);
lines.push('\tvar disc struct {');
lines.push(`\t\t${discGoName} string \`json:"${discField}"\``);
if (u.unionDeserializer === 'webhook-payload') {
lines.push('\t\tEvent string `json:"event"`');
}
lines.push('\t}');
lines.push('\tif err := json.Unmarshal(data, &disc); err != nil {');
lines.push('\t\treturn err');
lines.push('\t}');
lines.push(`\tswitch disc.${discGoName} {`);
const seenDiscs = new Set<string>();
for (const ref of u.memberRefs) {
const model = models.find((m) => m.name === ref);
const typeField = model?.fields.find((f) => f.type.kind === 'literal');
const disc = typeField?.type.literalValue ?? ref;
if (seenDiscs.has(String(disc))) continue;
seenDiscs.add(String(disc));
lines.push(`\tcase ${JSON.stringify(disc)}:`);
lines.push(`\t\tu.${ref} = &${ref}{}`);
lines.push(`\t\treturn json.Unmarshal(data, u.${ref})`);
if (u.unionDeserializer === 'webhook-payload') {
lines.push('\tswitch {');
lines.push('\tcase disc.Type == "message" && disc.Event == "link_shared":');
lines.push('\t\tu.LinkSharedWebhookPayload = &LinkSharedWebhookPayload{}');
lines.push('\t\treturn json.Unmarshal(data, u.LinkSharedWebhookPayload)');
lines.push('\tcase disc.Type == "message":');
lines.push('\t\tu.MessageWebhookPayload = &MessageWebhookPayload{}');
lines.push('\t\treturn json.Unmarshal(data, u.MessageWebhookPayload)');
for (const ref of u.memberRefs.filter((ref) => ref !== 'MessageWebhookPayload' && ref !== 'LinkSharedWebhookPayload')) {
const model = models.find((m) => m.name === ref);
const typeField = model?.fields.find(
(f) => f.name === discField && f.type.kind === 'literal',
) ?? model?.fields.find((f) => f.type.kind === 'literal');
const disc = typeField?.type.literalValue ?? ref;
lines.push(`\tcase disc.${discGoName} == ${JSON.stringify(disc)}:`);
lines.push(`\t\tu.${ref} = &${ref}{}`);
lines.push(`\t\treturn json.Unmarshal(data, u.${ref})`);
}
} else {
lines.push(`\tswitch disc.${discGoName} {`);
const seenDiscs = new Set<string>();
for (const ref of u.memberRefs) {
const model = models.find((m) => m.name === ref);
const typeField = model?.fields.find(
(f) => f.name === discField && f.type.kind === 'literal',
) ?? model?.fields.find((f) => f.type.kind === 'literal');
const disc = typeField?.type.literalValue ?? ref;
if (seenDiscs.has(String(disc))) continue;
seenDiscs.add(String(disc));
lines.push(`\tcase ${JSON.stringify(disc)}:`);
lines.push(`\t\tu.${ref} = &${ref}{}`);
lines.push(`\t\treturn json.Unmarshal(data, u.${ref})`);
}
}
lines.push('\tdefault:');
lines.push(`\t\treturn fmt.Errorf("unknown ${u.name} ${discField}: %s", disc.${discGoName})`);
Expand Down
Loading
Loading