diff --git a/apps/docs/data/releases.json b/apps/docs/data/releases.json index 5a2175d7..23e6bcd5 100644 --- a/apps/docs/data/releases.json +++ b/apps/docs/data/releases.json @@ -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", diff --git a/apps/docs/public/updates.md b/apps/docs/public/updates.md index 929b17c9..8ed0104d 100644 --- a/apps/docs/public/updates.md +++ b/apps/docs/public/updates.md @@ -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 diff --git a/apps/docs/public/updates/2026-06-05.md b/apps/docs/public/updates/2026-06-05.md new file mode 100644 index 00000000..f63e5fad --- /dev/null +++ b/apps/docs/public/updates/2026-06-05.md @@ -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` diff --git a/apps/docs/public/updates/season/summer-2026.md b/apps/docs/public/updates/season/summer-2026.md index 98c35e96..e4ebf4a2 100644 --- a/apps/docs/public/updates/season/summer-2026.md +++ b/apps/docs/public/updates/season/summer-2026.md @@ -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 diff --git a/packages/generator/src/ir.ts b/packages/generator/src/ir.ts index 511e045a..504903ad 100644 --- a/packages/generator/src/ir.ts +++ b/packages/generator/src/ir.ts @@ -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 ----- diff --git a/packages/generator/src/lang/csharp.ts b/packages/generator/src/lang/csharp.ts index caba4eaa..d3d51bd3 100644 --- a/packages/generator/src/lang/csharp.ts +++ b/packages/generator/src/lang/csharp.ts @@ -287,12 +287,19 @@ 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('{'); @@ -300,27 +307,61 @@ function emitUnion( 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(raw, options)!,'); + lines.push(' "message" => JsonSerializer.Deserialize(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('}'); diff --git a/packages/generator/src/lang/go.ts b/packages/generator/src/lang/go.ts index d372c77a..404a10f5 100644 --- a/packages/generator/src/lang/go.ts +++ b/packages/generator/src/lang/go.ts @@ -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(); - 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(); + 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})`); diff --git a/packages/generator/src/lang/kotlin.ts b/packages/generator/src/lang/kotlin.ts index a6f7f0be..323dc378 100644 --- a/packages/generator/src/lang/kotlin.ts +++ b/packages/generator/src/lang/kotlin.ts @@ -132,6 +132,7 @@ function generateModels(ir: IR): string { // Determine imports let needSerialName = false; let needTransient = false; + const needWebhookPayloadUnionSerializer = ir.unions.some((u) => u.unionDeserializer === 'webhook-payload'); if (ir.enums.length > 0) needSerialName = true; if (ir.unions.length > 0) needSerialName = true; @@ -158,14 +159,21 @@ function generateModels(ir: IR): string { const imports: string[] = []; if (needDateTime) imports.push('import java.time.OffsetDateTime'); if (needDateTime) imports.push('import java.time.format.DateTimeFormatter'); - if (needDateTime) imports.push('import kotlinx.serialization.KSerializer'); + if (needDateTime || needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.KSerializer'); if (needSerialName) imports.push('import kotlinx.serialization.SerialName'); imports.push('import kotlinx.serialization.Serializable'); if (needTransient) imports.push('import kotlinx.serialization.Transient'); if (needDateTime) imports.push('import kotlinx.serialization.descriptors.PrimitiveKind'); if (needDateTime) imports.push('import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor'); - if (needDateTime) imports.push('import kotlinx.serialization.encoding.Decoder'); - if (needDateTime) imports.push('import kotlinx.serialization.encoding.Encoder'); + if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.descriptors.buildClassSerialDescriptor'); + if (needDateTime || needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.encoding.Decoder'); + if (needDateTime || needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.encoding.Encoder'); + if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.json.JsonDecoder'); + if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.json.JsonEncoder'); + if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.json.contentOrNull'); + if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.json.decodeFromJsonElement'); + if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.json.jsonObject'); + if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.json.jsonPrimitive'); lines.push(imports.join('\n')); if (needDateTime) { @@ -245,39 +253,99 @@ function emitUnion( .filter(Boolean) as IRModel[]; const discriminatorField = u.discriminatorField; + const useWebhookPayloadDeserializer = u.unionDeserializer === 'webhook-payload'; - lines.push('@Serializable'); + if (useWebhookPayloadDeserializer) { + lines.push('@Serializable(with = WebhookPayloadUnionSerializer::class)'); + } else { + lines.push('@Serializable'); + } lines.push(`sealed interface ${u.name} {`); lines.push(` val ${snakeToCamel(discriminatorField)}: String`); lines.push('}'); + if (useWebhookPayloadDeserializer) { + lines.push(''); + lines.push('object WebhookPayloadUnionSerializer : KSerializer {'); + lines.push(' override val descriptor = buildClassSerialDescriptor("WebhookPayloadUnion")'); + lines.push(''); + lines.push(' override fun serialize(encoder: Encoder, value: WebhookPayloadUnion) {'); + lines.push(' val jsonEncoder = encoder as? JsonEncoder ?: error("WebhookPayloadUnionSerializer only supports JSON")'); + lines.push(' when (value) {'); + lines.push(' is MessageWebhookPayload -> jsonEncoder.encodeSerializableValue(MessageWebhookPayload.serializer(), value)'); + lines.push(' is ReactionWebhookPayload -> jsonEncoder.encodeSerializableValue(ReactionWebhookPayload.serializer(), value)'); + lines.push(' is ButtonWebhookPayload -> jsonEncoder.encodeSerializableValue(ButtonWebhookPayload.serializer(), value)'); + lines.push(' is ViewSubmitWebhookPayload -> jsonEncoder.encodeSerializableValue(ViewSubmitWebhookPayload.serializer(), value)'); + lines.push(' is ChatMemberWebhookPayload -> jsonEncoder.encodeSerializableValue(ChatMemberWebhookPayload.serializer(), value)'); + lines.push(' is CompanyMemberWebhookPayload -> jsonEncoder.encodeSerializableValue(CompanyMemberWebhookPayload.serializer(), value)'); + lines.push(' is LinkSharedWebhookPayload -> jsonEncoder.encodeSerializableValue(LinkSharedWebhookPayload.serializer(), value)'); + lines.push(' }'); + lines.push(' }'); + lines.push(''); + lines.push(' override fun deserialize(decoder: Decoder): WebhookPayloadUnion {'); + lines.push(' val jsonDecoder = decoder as? JsonDecoder ?: error("WebhookPayloadUnionSerializer only supports JSON")'); + lines.push(' val element = jsonDecoder.decodeJsonElement()'); + lines.push(' val type = element.jsonObject["type"]?.jsonPrimitive?.contentOrNull'); + lines.push(' val event = element.jsonObject["event"]?.jsonPrimitive?.contentOrNull'); + lines.push(' return when {'); + lines.push(' type == "message" && event == "link_shared" -> jsonDecoder.json.decodeFromJsonElement(LinkSharedWebhookPayload.serializer(), element)'); + lines.push(' type == "message" -> jsonDecoder.json.decodeFromJsonElement(MessageWebhookPayload.serializer(), element)'); + lines.push(' type == "reaction" -> jsonDecoder.json.decodeFromJsonElement(ReactionWebhookPayload.serializer(), element)'); + lines.push(' type == "button" -> jsonDecoder.json.decodeFromJsonElement(ButtonWebhookPayload.serializer(), element)'); + lines.push(' type == "view" -> jsonDecoder.json.decodeFromJsonElement(ViewSubmitWebhookPayload.serializer(), element)'); + lines.push(' type == "chat_member" -> jsonDecoder.json.decodeFromJsonElement(ChatMemberWebhookPayload.serializer(), element)'); + lines.push(' type == "company_member" -> jsonDecoder.json.decodeFromJsonElement(CompanyMemberWebhookPayload.serializer(), element)'); + lines.push(' else -> error("Unknown WebhookPayloadUnion type: $type")'); + lines.push(' }'); + lines.push(' }'); + lines.push('}'); + } + for (const memberModel of memberModels) { - const litField = memberModel.fields.find((f) => f.type.kind === 'literal'); + 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 ?? ''; - const otherFields = memberModel.fields.filter( - (f) => f.type.kind !== 'literal', - ); lines.push(''); lines.push('@Serializable'); lines.push(`@SerialName("${litValue}")`); lines.push(`data class ${memberModel.name}(`); - lines.push( - ` override val ${snakeToCamel(discriminatorField)}: String = "${litValue}",`, - ); - for (const f of otherFields) { + if (!memberModel.fields.some((f) => f.name === discriminatorField)) { + lines.push( + ` override val ${snakeToCamel(discriminatorField)}: String = "${litValue}",`, + ); + } + const bodyLiteralFields: IRField[] = []; + for (const f of memberModel.fields) { + if (f.name !== discriminatorField && f.type.kind === 'literal') { + bodyLiteralFields.push(f); + continue; + } const sdkName = fieldSdkName(f); const typeName = ktType(f.type); const isOpt = !f.required; const fullType = isOpt ? `${typeName}?` : typeName; - const default_ = isOpt ? ' = null' : ''; + const literalDefault = f.type.kind === 'literal' ? ` = "${f.type.literalValue ?? ''}"` : ''; + const default_ = isOpt ? ' = null' : literalDefault; const isDateTime = f.type.kind === 'primitive' && f.type.primitive === 'string' && f.type.format === 'date-time'; const dtAnnotation = isDateTime ? '@Serializable(with = OffsetDateTimeSerializer::class) ' : ''; const serialName = needsSerialName(f) ? `${dtAnnotation}@SerialName("${f.name}") ` : dtAnnotation; - lines.push(` ${serialName}val ${sdkName}: ${fullType}${default_},`); + const overrideModifier = f.name === discriminatorField ? 'override ' : ''; + lines.push(` ${serialName}${overrideModifier}val ${sdkName}: ${fullType}${default_},`); + } + if (bodyLiteralFields.length === 0) { + lines.push(`) : ${u.name}`); + } else { + lines.push(`) : ${u.name} {`); + for (const f of bodyLiteralFields) { + const sdkName = fieldSdkName(f); + const serialName = needsSerialName(f) ? `@SerialName("${f.name}") ` : ''; + lines.push(` ${serialName}val ${sdkName}: String = "${f.type.literalValue ?? ''}"`); + } + lines.push('}'); } - lines.push(`) : ${u.name}`); } } diff --git a/packages/generator/src/lang/python.ts b/packages/generator/src/lang/python.ts index 23d4010a..f7cf0a45 100644 --- a/packages/generator/src/lang/python.ts +++ b/packages/generator/src/lang/python.ts @@ -881,26 +881,44 @@ function generateClient(ir: IR): { content: string; needUtils: boolean } { return { content: lines.join('\n'), needUtils }; } -function generateUtils(): string { - return [ +function generateUtils(ir: IR): string { + const lines: string[] = [ 'from __future__ import annotations', '', 'import dataclasses', 'import keyword', 'from dataclasses import asdict, fields', 'from datetime import datetime', - 'from typing import Type, TypeVar, get_args, get_origin, get_type_hints', + 'from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints', '', 'import httpx', + ]; + + const customUnions = ir.unions.filter((u) => u.unionDeserializer === 'webhook-payload'); + if (customUnions.length > 0) { + const importNames = new Set(); + for (const u of customUnions) { + importNames.add(u.name); + for (const ref of u.memberRefs) importNames.add(ref); + } + lines.push(''); + lines.push('from .models import ('); + for (const name of [...importNames].sort()) { + lines.push(` ${name},`); + } + lines.push(')'); + } + + lines.push( '', 'T = TypeVar("T")', '', '', - 'def _is_dataclass_type(tp: type) -> bool:', + 'def _is_dataclass_type(tp: object) -> bool:', ' return isinstance(tp, type) and dataclasses.is_dataclass(tp)', '', '', - 'def _resolve_type(tp: type) -> type | None:', + 'def _resolve_type(tp: object) -> type | None:', ' """Extract a concrete dataclass type from Optional[X] or X | None."""', ' origin = get_origin(tp)', ' if origin is list:', @@ -914,7 +932,7 @@ function generateUtils(): string { ' return None', '', '', - 'def _resolve_list_item_type(tp: type) -> type | None:', + 'def _resolve_list_item_type(tp: object) -> object | None:', ' """Extract the item type from list[X]."""', ' origin = get_origin(tp)', ' if origin is list:', @@ -924,8 +942,34 @@ function generateUtils(): string { ' return None', '', '', - 'def deserialize(cls: Type[T], data: dict) -> T:', - ' """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""', + 'CustomUnionDeserializer = Callable[[dict], object]', + '', + '', + 'def _deserialize_instance(tp: object, value: object) -> object:', + ' custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)', + ' if custom is not None and isinstance(value, dict):', + ' return custom(value)', + ' if isinstance(value, dict):', + ' nested = _resolve_type(tp)', + ' if nested is not None:', + ' return _deserialize_dataclass(nested, value)', + ' if isinstance(value, list):', + ' item_tp = _resolve_list_item_type(tp)', + ' if item_tp is not None:', + ' return [_deserialize_instance(item_tp, item) for item in value]', + ' if isinstance(value, str):', + ' raw_tp = tp', + ' if get_origin(tp) is not None:', + ' for arg in get_args(tp):', + ' if arg is not type(None):', + ' raw_tp = arg', + ' break', + ' if raw_tp is datetime:', + ' return datetime.fromisoformat(value)', + ' return value', + '', + '', + 'def _deserialize_dataclass(cls: Type[T], data: dict) -> T:', ' field_map = {f.name: f for f in fields(cls)}', ' hints = get_type_hints(cls)', ' norm = {k.replace("-", "_").lower(): v for k, v in data.items()}', @@ -936,79 +980,103 @@ function generateUtils(): string { ' if k not in field_map:', ' continue', ' f = field_map[k]', - ' if isinstance(v, dict):', - ' nested = _resolve_type(hints[f.name])', - ' if nested is not None:', - ' v = deserialize(nested, v)', - ' elif isinstance(v, list) and v:', - ' item_tp = _resolve_list_item_type(hints[f.name])', - ' if item_tp is not None and _is_dataclass_type(item_tp):', - ' v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]', - ' elif isinstance(v, str):', - ' hint = hints.get(f.name)', - ' raw_hint = hint', - ' if get_origin(hint) is not None:', - ' for a in get_args(hint):', - ' if a is not type(None):', - ' raw_hint = a', - ' break', - ' if raw_hint is datetime:', - ' v = datetime.fromisoformat(v)', - ' kwargs[k] = v', + ' kwargs[k] = _deserialize_instance(hints[f.name], v)', ' return cls(**kwargs)', - '', - '', - 'def _strip_nones(val: object) -> object:', - ' if isinstance(val, dict):', - ' return {', - ' (k[:-1] if k.endswith("_") and keyword.iskeyword(k[:-1]) else k): _strip_nones(v)', - ' for k, v in val.items() if v is not None', - ' }', - ' if isinstance(val, list):', - ' return [_strip_nones(v) for v in val]', - ' if isinstance(val, datetime):', - ' return val.isoformat()', - ' return val', - '', - '', - 'def serialize(obj: object) -> dict:', - ' """Convert a dataclass to a dict, recursively omitting None values."""', - ' return _strip_nones(asdict(obj))', - '', - '', - '_MAX_RETRIES = 3', - '_RETRYABLE_5XX = {500, 502, 503, 504}', - '', - '', - 'def _jitter(delay: float) -> float:', - ' import random', - ' return delay * (0.5 + random.random() * 0.5)', - '', - '', - 'class RetryTransport(httpx.AsyncBaseTransport):', - ' """Wraps an httpx transport with retry on 429 Too Many Requests and 5xx errors."""', - '', - ' def __init__(self, transport: httpx.AsyncBaseTransport, max_retries: int = _MAX_RETRIES) -> None:', - ' self._transport = transport', - ' self._max_retries = max_retries', - '', - ' async def handle_async_request(self, request: httpx.Request) -> httpx.Response:', - ' import asyncio', - ' for attempt in range(self._max_retries + 1):', - ' response = await self._transport.handle_async_request(request)', - ' if response.status_code == 429 and attempt < self._max_retries:', - ' retry_after = response.headers.get("retry-after")', - ' delay = int(retry_after) if retry_after and retry_after.isdigit() else 2 ** attempt', - ' await asyncio.sleep(_add_jitter(delay))', - ' continue', - ' if response.status_code in _RETRYABLE_5XX and attempt < self._max_retries:', - ' delay = attempt + 1', - ' await asyncio.sleep(_add_jitter(delay))', - ' continue', - ' return response', - ' return response # unreachable', - '', - ].join('\n'); + '' + ); + + if (customUnions.length > 0) { + for (const u of customUnions) { + const fnName = `_${camelToSnake(u.name)}_deserialize`; + if (u.unionDeserializer === 'webhook-payload') { + lines.push(`def ${fnName}(data: dict) -> ${u.name}:`); + lines.push(' match (data.get("type"), data.get("event")):'); + lines.push(' case ("message", "link_shared"):'); + lines.push(' return _deserialize_instance(LinkSharedWebhookPayload, data)'); + lines.push(' case ("message", _):'); + lines.push(' return _deserialize_instance(MessageWebhookPayload, data)'); + for (const ref of u.memberRefs.filter((ref) => ref !== 'MessageWebhookPayload' && ref !== 'LinkSharedWebhookPayload')) { + const model = ir.models.find((m) => m.name === ref); + const typeField = model?.fields.find( + (f) => f.name === u.discriminatorField && f.type.kind === 'literal', + ) ?? model?.fields.find((f) => f.type.kind === 'literal'); + const disc = typeField?.type.literalValue; + if (disc) { + lines.push(` case (${JSON.stringify(disc)}, _):`); + lines.push(` return _deserialize_instance(${ref}, data)`); + } + } + lines.push(' case _:'); + lines.push(` raise ValueError(f"Unknown ${u.name} discriminator: {data.get('type')}")`); + lines.push(''); + } + } + } + + lines.push('_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {'); + for (const u of customUnions) { + lines.push(` ${u.name}: _${camelToSnake(u.name)}_deserialize,`); + } + lines.push('}'); + lines.push(''); + lines.push(''); + lines.push('def deserialize(cls: Type[T], data: dict) -> T:'); + lines.push(' """Create a typed instance from a dict, recursively deserializing nested values."""'); + lines.push(' return _deserialize_instance(cls, data)'); + lines.push(''); + lines.push(''); + lines.push('def _strip_nones(val: object) -> object:'); + lines.push(' if isinstance(val, dict):'); + lines.push(' return {'); + lines.push(' (k[:-1] if k.endswith("_") and keyword.iskeyword(k[:-1]) else k): _strip_nones(v)'); + lines.push(' for k, v in val.items() if v is not None'); + lines.push(' }'); + lines.push(' if isinstance(val, list):'); + lines.push(' return [_strip_nones(v) for v in val]'); + lines.push(' if isinstance(val, datetime):'); + lines.push(' return val.isoformat()'); + lines.push(' return val'); + lines.push(''); + lines.push(''); + lines.push('def serialize(obj: object) -> dict:'); + lines.push(' """Convert a dataclass to a dict, recursively omitting None values."""'); + lines.push(' return _strip_nones(asdict(obj))'); + lines.push(''); + lines.push(''); + lines.push('_MAX_RETRIES = 3'); + lines.push('_RETRYABLE_5XX = {500, 502, 503, 504}'); + lines.push(''); + lines.push(''); + lines.push('def _jitter(delay: float) -> float:'); + lines.push(' import random'); + lines.push(' return delay * (0.5 + random.random() * 0.5)'); + lines.push(''); + lines.push(''); + lines.push('class RetryTransport(httpx.AsyncBaseTransport):'); + lines.push(' """Wraps an httpx transport with retry on 429 Too Many Requests and 5xx errors."""'); + lines.push(''); + lines.push(' def __init__(self, transport: httpx.AsyncBaseTransport, max_retries: int = _MAX_RETRIES) -> None:'); + lines.push(' self._transport = transport'); + lines.push(' self._max_retries = max_retries'); + lines.push(''); + lines.push(' async def handle_async_request(self, request: httpx.Request) -> httpx.Response:'); + lines.push(' import asyncio'); + lines.push(' for attempt in range(self._max_retries + 1):'); + lines.push(' response = await self._transport.handle_async_request(request)'); + lines.push(' if response.status_code == 429 and attempt < self._max_retries:'); + lines.push(' retry_after = response.headers.get("retry-after")'); + lines.push(' delay = int(retry_after) if retry_after and retry_after.isdigit() else 2 ** attempt'); + lines.push(' await asyncio.sleep(_add_jitter(delay))'); + lines.push(' continue'); + lines.push(' if response.status_code in _RETRYABLE_5XX and attempt < self._max_retries:'); + lines.push(' delay = attempt + 1'); + lines.push(' await asyncio.sleep(_add_jitter(delay))'); + lines.push(' continue'); + lines.push(' return response'); + lines.push(' return response # unreachable'); + lines.push(''); + + return lines.join('\n'); } // ── Examples ────────────────────────────────────────────────────────── @@ -1306,7 +1374,7 @@ export class PythonGenerator implements LanguageGenerator { const client = generateClient(ir); files.push({ path: 'client.py', content: client.content }); if (client.needUtils) { - files.push({ path: 'utils.py', content: generateUtils() }); + files.push({ path: 'utils.py', content: generateUtils(ir) }); } if (options?.examples) { files.push({ path: 'examples.json', content: generateExamples(ir) }); diff --git a/packages/generator/src/lang/swift.ts b/packages/generator/src/lang/swift.ts index 60232c8b..b3cc1298 100644 --- a/packages/generator/src/lang/swift.ts +++ b/packages/generator/src/lang/swift.ts @@ -181,22 +181,46 @@ function emitUnion(lines: string[], u: IRUnion, models: IRModel[]): void { } else { lines.push(` case ${discSwiftName} = ${JSON.stringify(discField)}`); } + if (u.unionDeserializer === 'webhook-payload') { + lines.push(' case event'); + } lines.push(' }'); lines.push(''); lines.push(' public init(from decoder: Decoder) throws {'); lines.push(' let container = try decoder.container(keyedBy: CodingKeys.self)'); lines.push(` let type = try container.decode(String.self, forKey: .${discSwiftName})`); - lines.push(' switch type {'); - const seenDiscs = new Set(); - for (const ref of u.memberRefs) { - const c = ref.charAt(0).toLowerCase() + ref.slice(1); - const model = models.find((m) => m.name === ref); - const typeField = model?.fields.find((f) => f.type.kind === 'literal'); - const disc = typeField?.type.literalValue ?? c; - if (seenDiscs.has(String(disc))) continue; - seenDiscs.add(String(disc)); - lines.push(` case ${JSON.stringify(disc)}:`); - lines.push(` self = .${c}(try ${ref}(from: decoder))`); + if (u.unionDeserializer === 'webhook-payload') { + lines.push(' let event = try? container.decode(String.self, forKey: .event)'); + lines.push(' switch (type, event) {'); + lines.push(' case ("message", "link_shared"):'); + lines.push(' self = .linkSharedWebhookPayload(try LinkSharedWebhookPayload(from: decoder))'); + lines.push(' case ("message", _):'); + lines.push(' self = .messageWebhookPayload(try MessageWebhookPayload(from: decoder))'); + for (const ref of u.memberRefs.filter((ref) => ref !== 'MessageWebhookPayload' && ref !== 'LinkSharedWebhookPayload')) { + const c = ref.charAt(0).toLowerCase() + ref.slice(1); + 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 ?? c; + lines.push(` case (${JSON.stringify(disc)}, _):`); + lines.push(` self = .${c}(try ${ref}(from: decoder))`); + } + } else { + lines.push(' switch type {'); + const seenDiscs = new Set(); + for (const ref of u.memberRefs) { + const c = ref.charAt(0).toLowerCase() + ref.slice(1); + 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 ?? c; + if (seenDiscs.has(String(disc))) continue; + seenDiscs.add(String(disc)); + lines.push(` case ${JSON.stringify(disc)}:`); + lines.push(` self = .${c}(try ${ref}(from: decoder))`); + } } lines.push(' default:'); lines.push(' throw DecodingError.dataCorrupted('); diff --git a/packages/generator/src/lang/typescript.ts b/packages/generator/src/lang/typescript.ts index d2a4bc36..10df71f8 100644 --- a/packages/generator/src/lang/typescript.ts +++ b/packages/generator/src/lang/typescript.ts @@ -446,7 +446,8 @@ function generateClient(ir: IR): { content: string; needsUtils: boolean } { if (needsDeserialize || needsSerialize || hasServices) { const utils = [ needsDeserialize ? 'deserialize' : null, - needsSerialize ? 'serialize' : null, + needsDeserialize ? 'deserializeType' : null, + needsSerialize ? 'serializeType' : null, hasServices ? 'fetchWithRetry' : null, ] .filter((x): x is string => !!x) @@ -692,7 +693,7 @@ function emitOperation(lines: string[], op: IROperation, ir: IR): void { const sdk = snakeToCamel(f.name); lines.push(` body: JSON.stringify({ ${f.name}: ${sdk} }),`); } else { - lines.push(' body: JSON.stringify(serialize(request)),'); + lines.push(` body: JSON.stringify(serializeType(${JSON.stringify(rb.schemaRef)}, request)),`); } } lines.push(' });'); @@ -757,6 +758,13 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { lines.push(' }'); } +function responseNeedsTypedDataItems(op: IROperation, ir: IR): boolean { + if (!op.successResponse.dataRef) return false; + const customUnionNames = new Set(ir.unions.filter((u) => u.unionDeserializer).map((u) => u.name)); + const model = ir.models.find((m) => m.name === op.successResponse.dataRef); + return !!model && hasDirectCustomUnionField(model, customUnionNames); +} + function emitResponseSwitch( lines: string[], op: IROperation, @@ -777,13 +785,17 @@ function emitResponseSwitch( lines.push(' return;'); } else if (op.successResponse.isList) { lines.push(` case ${op.successResponse.statusCode}:`); - lines.push(` return deserialize(body) as ${responseTypeName(op, ir)};`); + if (responseNeedsTypedDataItems(op, ir) && op.successResponse.dataRef) { + lines.push(` return { ...(deserialize(body) as ${responseTypeName(op, ir)}), data: Array.isArray(body.data) ? body.data.map((item: unknown) => deserializeType(${JSON.stringify(op.successResponse.dataRef)}, item) as ${op.successResponse.dataRef}) : [] } as ${responseTypeName(op, ir)};`); + } else { + lines.push(` return deserialize(body) as ${responseTypeName(op, ir)};`); + } } else if (op.successResponse.isUnwrap && op.successResponse.dataRef) { lines.push(` case ${op.successResponse.statusCode}:`); - lines.push(` return deserialize(body.data) as ${op.successResponse.dataRef};`); + lines.push(` return deserializeType(${JSON.stringify(op.successResponse.dataRef)}, body.data) as ${op.successResponse.dataRef};`); } else { lines.push(` case ${op.successResponse.statusCode}:`); - lines.push(` return deserialize(body) as ${responseTypeName(op, ir)};`); + lines.push(` return deserializeType(${JSON.stringify(responseTypeName(op, ir))}, body) as ${responseTypeName(op, ir)};`); } if (op.hasOAuthError) { @@ -820,8 +832,94 @@ function collectRecordKeys(ir: IR): Set { return keys; } +function hasDirectCustomUnionField(model: IRModel, customUnionNames: Set): boolean { + return model.fields.some((field) => + (field.type.kind === 'union' || field.type.kind === 'model') + && !!field.type.ref + && customUnionNames.has(field.type.ref), + ); +} + +function renderTransformExpr( + ft: IRFieldType, + mode: 'deserialize' | 'serialize', + valueName: string, + customUnionNames: Set, +): string { + const typeFn = mode === 'deserialize' ? 'deserializeType' : 'serializeType'; + const valueFn = mode === 'deserialize' ? 'deserialize' : 'serialize'; + const arrayFn = mode === 'deserialize' ? 'deserializeArray' : 'serializeArray'; + const recordFn = mode === 'deserialize' ? 'deserializeRecordWith' : 'serializeRecordWith'; + + switch (ft.kind) { + case 'model': + case 'union': + if (ft.ref && customUnionNames.has(ft.ref)) return `${valueFn}(${valueName})`; + return ft.ref ? `${typeFn}(${JSON.stringify(ft.ref)}, ${valueName})` : `${valueFn}(${valueName})`; + case 'array': + return `${arrayFn}(${valueName}, (item) => ${renderTransformExpr(ft.items!, mode, 'item', customUnionNames)})`; + case 'record': + return `${recordFn}(${valueName}, (entryValue) => ${renderTransformExpr(ft.valueType!, mode, 'entryValue', customUnionNames)})`; + default: + return `${valueFn}(${valueName})`; + } +} + +function emitObjectTransform( + lines: string[], + name: string, + fields: IRField[], + mode: 'deserialize' | 'serialize', + hasRecords: boolean, + customUnionNames: Set, +): void { + const fnName = `${mode}${name}`; + const fallback = mode === 'deserialize' + ? hasRecords + ? '[ck, RECORD_KEYS.has(ck) ? deserializeRecordWith(v, deserialize) : deserialize(v)]' + : '[ck, deserialize(v)]' + : hasRecords + ? '[camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecordWith(v, serialize) : serialize(v)]' + : '[camelToSnake(k), serialize(v)]'; + + lines.push(`function ${fnName}(obj: unknown): unknown {`); + lines.push(` if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return ${mode}(obj);`); + lines.push(' return Object.fromEntries('); + lines.push(' Object.entries(obj)'); + if (mode === 'serialize') lines.push(' .filter(([, v]) => v !== undefined)'); + lines.push(' .map(([k, v]) => {'); + if (mode === 'deserialize') { + lines.push(' const ck = snakeToCamel(k);'); + lines.push(' switch (ck) {'); + for (const field of fields) { + const sdkName = fieldSdkName(field); + lines.push(` case ${JSON.stringify(sdkName)}:`); + lines.push(` return [ck, ${renderTransformExpr(field.type, mode, 'v', customUnionNames)}];`); + } + } else { + lines.push(' switch (k) {'); + for (const field of fields) { + const sdkName = fieldSdkName(field); + lines.push(` case ${JSON.stringify(sdkName)}:`); + lines.push(` return [camelToSnake(k), ${renderTransformExpr(field.type, mode, 'v', customUnionNames)}];`); + } + } + lines.push(' default:'); + lines.push(` return ${fallback};`); + lines.push(' }'); + lines.push(' }),'); + lines.push(' );'); + lines.push('}'); + lines.push(''); +} + +function emitCustomUnionTransforms(_lines: string[], _ir: IR): void {} + function generateUtils(ir: IR): string { const recordKeys = collectRecordKeys(ir); + const customUnionNames = new Set(ir.unions.filter((u) => u.unionDeserializer).map((u) => u.name)); + const targetModels = ir.models.filter((m) => hasDirectCustomUnionField(m, customUnionNames)); + const lines: string[] = [ 'function snakeToCamel(str: string): string {', ' const camel = str.replace(/[-_]([a-zA-Z])/g, (_, c) => c.toUpperCase());', @@ -841,39 +939,41 @@ function generateUtils(ir: IR): string { const keyList = [...recordKeys].map((k) => JSON.stringify(k)).join(', '); lines.push(`const RECORD_KEYS = new Set([${keyList}]);`); lines.push(''); - lines.push('function deserializeRecord(obj: unknown): unknown {'); - lines.push(' if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {'); - lines.push(' return Object.fromEntries('); - lines.push(' Object.entries(obj).map(([k, v]) => [k, deserialize(v)]),'); - lines.push(' );'); - lines.push(' }'); - lines.push(' return deserialize(obj);'); - lines.push('}'); - lines.push(''); - lines.push('function serializeRecord(obj: unknown): unknown {'); - lines.push(' if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {'); - lines.push(' return Object.fromEntries('); - lines.push(' Object.entries(obj)'); - lines.push(' .filter(([, v]) => v !== undefined)'); - lines.push(' .map(([k, v]) => [k, serialize(v)]),'); - lines.push(' );'); - lines.push(' }'); - lines.push(' return serialize(obj);'); - lines.push('}'); - lines.push(''); } - const deserializeValue = hasRecords - ? '([k, v]) => {\n const ck = snakeToCamel(k);\n return [ck, RECORD_KEYS.has(ck) ? deserializeRecord(v) : deserialize(v)];\n }' - : '([k, v]) => [snakeToCamel(k), deserialize(v)]'; - const serializeValue = hasRecords - ? '([k, v]) => {\n return [camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecord(v) : serialize(v)];\n }' - : '([k, v]) => [camelToSnake(k), serialize(v)]'; + lines.push('function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {'); + lines.push(' return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);'); + lines.push('}'); + lines.push(''); + lines.push('function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {'); + lines.push(' return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);'); + lines.push('}'); + lines.push(''); + lines.push('function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {'); + lines.push(' if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {'); + lines.push(' return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));'); + lines.push(' }'); + lines.push(' return deserialize(obj);'); + lines.push('}'); + lines.push(''); + lines.push('function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {'); + lines.push(' if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {'); + lines.push(' return Object.fromEntries('); + lines.push(' Object.entries(obj)'); + lines.push(' .filter(([, v]) => v !== undefined)'); + lines.push(' .map(([k, v]) => [k, mapValue(v)]),'); + lines.push(' );'); + lines.push(' }'); + lines.push(' return serialize(obj);'); + lines.push('}'); + lines.push(''); lines.push( 'export function deserialize(obj: unknown): unknown {', ' if (Array.isArray(obj)) return obj.map(deserialize);', ' if (obj !== null && typeof obj === "object") {', ' return Object.fromEntries(', - ` Object.entries(obj).map(${deserializeValue}),`, + hasRecords + ? ' Object.entries(obj).map(([k, v]) => {\n const ck = snakeToCamel(k);\n return [ck, RECORD_KEYS.has(ck) ? deserializeRecordWith(v, deserialize) : deserialize(v)];\n }),' + : ' Object.entries(obj).map(([k, v]) => [snakeToCamel(k), deserialize(v)]),', ' );', ' }', ' return obj;', @@ -885,12 +985,34 @@ function generateUtils(ir: IR): string { ' return Object.fromEntries(', ' Object.entries(obj)', ' .filter(([, v]) => v !== undefined)', - ` .map(${serializeValue}),`, + hasRecords + ? ' .map(([k, v]) => [camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecordWith(v, serialize) : serialize(v)]),' + : ' .map(([k, v]) => [camelToSnake(k), serialize(v)]),', ' );', ' }', ' return obj;', '}', + '', ); + + for (const model of targetModels) { + emitObjectTransform(lines, model.name, model.fields, 'deserialize', hasRecords, customUnionNames); + } + + emitCustomUnionTransforms(lines, ir); + + lines.push('const TYPE_DESERIALIZERS: Record unknown> = {'); + for (const model of targetModels) lines.push(` ${JSON.stringify(model.name)}: deserialize${model.name},`); + lines.push('};'); + lines.push(''); + lines.push('export function deserializeType(type: string, obj: unknown): unknown {'); + lines.push(' return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);'); + lines.push('}'); + lines.push(''); + lines.push('export function serializeType(_type: string, obj: unknown): unknown {'); + lines.push(' return serialize(obj);'); + lines.push('}'); + return [...lines, '', 'const MAX_RETRIES = 3;', diff --git a/packages/generator/src/transform.ts b/packages/generator/src/transform.ts index f332a1f6..3e34a9d9 100644 --- a/packages/generator/src/transform.ts +++ b/packages/generator/src/transform.ts @@ -343,7 +343,12 @@ function transformUnion(name: string, schema: Schema, schemas: Record s.$ref) .map((s) => refName(s.$ref!)); const discriminatorField = detectDiscriminatorField(schema, memberRefs, schemas); - return { name, memberRefs, discriminatorField }; + return { + name, + memberRefs, + discriminatorField, + unionDeserializer: schema['x-union-deserializer'], + }; } // ----- Operations → IR ----- diff --git a/packages/generator/tests/array-no-brackets/snapshots/py/utils.py b/packages/generator/tests/array-no-brackets/snapshots/py/utils.py index 28f6ac8b..a42b76a5 100644 --- a/packages/generator/tests/array-no-brackets/snapshots/py/utils.py +++ b/packages/generator/tests/array-no-brackets/snapshots/py/utils.py @@ -4,18 +4,18 @@ import keyword from dataclasses import asdict, fields from datetime import datetime -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints import httpx T = TypeVar("T") -def _is_dataclass_type(tp: type) -> bool: +def _is_dataclass_type(tp: object) -> bool: return isinstance(tp, type) and dataclasses.is_dataclass(tp) -def _resolve_type(tp: type) -> type | None: +def _resolve_type(tp: object) -> type | None: """Extract a concrete dataclass type from Optional[X] or X | None.""" origin = get_origin(tp) if origin is list: @@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None: return None -def _resolve_list_item_type(tp: type) -> type | None: +def _resolve_list_item_type(tp: object) -> object | None: """Extract the item type from list[X].""" origin = get_origin(tp) if origin is list: @@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None: return None -def deserialize(cls: Type[T], data: dict) -> T: - """Create a dataclass instance from a dict, recursively deserializing nested dataclasses.""" +CustomUnionDeserializer = Callable[[dict], object] + + +def _deserialize_instance(tp: object, value: object) -> object: + custom = _CUSTOM_UNION_DESERIALIZERS.get(tp) + if custom is not None and isinstance(value, dict): + return custom(value) + if isinstance(value, dict): + nested = _resolve_type(tp) + if nested is not None: + return _deserialize_dataclass(nested, value) + if isinstance(value, list): + item_tp = _resolve_list_item_type(tp) + if item_tp is not None: + return [_deserialize_instance(item_tp, item) for item in value] + if isinstance(value, str): + raw_tp = tp + if get_origin(tp) is not None: + for arg in get_args(tp): + if arg is not type(None): + raw_tp = arg + break + if raw_tp is datetime: + return datetime.fromisoformat(value) + return value + + +def _deserialize_dataclass(cls: Type[T], data: dict) -> T: field_map = {f.name: f for f in fields(cls)} hints = get_type_hints(cls) norm = {k.replace("-", "_").lower(): v for k, v in data.items()} @@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T: if k not in field_map: continue f = field_map[k] - if isinstance(v, dict): - nested = _resolve_type(hints[f.name]) - if nested is not None: - v = deserialize(nested, v) - elif isinstance(v, list) and v: - item_tp = _resolve_list_item_type(hints[f.name]) - if item_tp is not None and _is_dataclass_type(item_tp): - v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v] - elif isinstance(v, str): - hint = hints.get(f.name) - raw_hint = hint - if get_origin(hint) is not None: - for a in get_args(hint): - if a is not type(None): - raw_hint = a - break - if raw_hint is datetime: - v = datetime.fromisoformat(v) - kwargs[k] = v + kwargs[k] = _deserialize_instance(hints[f.name], v) return cls(**kwargs) +_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { +} + + +def deserialize(cls: Type[T], data: dict) -> T: + """Create a typed instance from a dict, recursively deserializing nested values.""" + return _deserialize_instance(cls, data) + def _strip_nones(val: object) -> object: if isinstance(val, dict): diff --git a/packages/generator/tests/array-no-brackets/snapshots/ts/client.ts b/packages/generator/tests/array-no-brackets/snapshots/ts/client.ts index 6f272e3f..4f292ed5 100644 --- a/packages/generator/tests/array-no-brackets/snapshots/ts/client.ts +++ b/packages/generator/tests/array-no-brackets/snapshots/ts/client.ts @@ -4,7 +4,7 @@ import { MessageResult, OAuthError, } from "./types.js"; -import { deserialize, fetchWithRetry } from "./utils.js"; +import { deserialize, deserializeType, fetchWithRetry } from "./utils.js"; export class SearchService { async searchMessages(params: SearchMessagesParams): Promise { diff --git a/packages/generator/tests/array-no-brackets/snapshots/ts/utils.ts b/packages/generator/tests/array-no-brackets/snapshots/ts/utils.ts index a471ae4d..35778d63 100644 --- a/packages/generator/tests/array-no-brackets/snapshots/ts/utils.ts +++ b/packages/generator/tests/array-no-brackets/snapshots/ts/utils.ts @@ -10,6 +10,32 @@ function camelToSnake(str: string): string { .toLowerCase(); } +function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj); +} + +function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj); +} + +function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)])); + } + return deserialize(obj); +} + +function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, mapValue(v)]), + ); + } + return serialize(obj); +} + export function deserialize(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(deserialize); if (obj !== null && typeof obj === "object") { @@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown { return obj; } +const TYPE_DESERIALIZERS: Record unknown> = { +}; + +export function deserializeType(type: string, obj: unknown): unknown { + return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj); +} + +export function serializeType(_type: string, obj: unknown): unknown { + return serialize(obj); +} + const MAX_RETRIES = 3; const RETRYABLE_5XX = new Set([500, 502, 503, 504]); diff --git a/packages/generator/tests/crud/snapshots/py/utils.py b/packages/generator/tests/crud/snapshots/py/utils.py index 28f6ac8b..a42b76a5 100644 --- a/packages/generator/tests/crud/snapshots/py/utils.py +++ b/packages/generator/tests/crud/snapshots/py/utils.py @@ -4,18 +4,18 @@ import keyword from dataclasses import asdict, fields from datetime import datetime -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints import httpx T = TypeVar("T") -def _is_dataclass_type(tp: type) -> bool: +def _is_dataclass_type(tp: object) -> bool: return isinstance(tp, type) and dataclasses.is_dataclass(tp) -def _resolve_type(tp: type) -> type | None: +def _resolve_type(tp: object) -> type | None: """Extract a concrete dataclass type from Optional[X] or X | None.""" origin = get_origin(tp) if origin is list: @@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None: return None -def _resolve_list_item_type(tp: type) -> type | None: +def _resolve_list_item_type(tp: object) -> object | None: """Extract the item type from list[X].""" origin = get_origin(tp) if origin is list: @@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None: return None -def deserialize(cls: Type[T], data: dict) -> T: - """Create a dataclass instance from a dict, recursively deserializing nested dataclasses.""" +CustomUnionDeserializer = Callable[[dict], object] + + +def _deserialize_instance(tp: object, value: object) -> object: + custom = _CUSTOM_UNION_DESERIALIZERS.get(tp) + if custom is not None and isinstance(value, dict): + return custom(value) + if isinstance(value, dict): + nested = _resolve_type(tp) + if nested is not None: + return _deserialize_dataclass(nested, value) + if isinstance(value, list): + item_tp = _resolve_list_item_type(tp) + if item_tp is not None: + return [_deserialize_instance(item_tp, item) for item in value] + if isinstance(value, str): + raw_tp = tp + if get_origin(tp) is not None: + for arg in get_args(tp): + if arg is not type(None): + raw_tp = arg + break + if raw_tp is datetime: + return datetime.fromisoformat(value) + return value + + +def _deserialize_dataclass(cls: Type[T], data: dict) -> T: field_map = {f.name: f for f in fields(cls)} hints = get_type_hints(cls) norm = {k.replace("-", "_").lower(): v for k, v in data.items()} @@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T: if k not in field_map: continue f = field_map[k] - if isinstance(v, dict): - nested = _resolve_type(hints[f.name]) - if nested is not None: - v = deserialize(nested, v) - elif isinstance(v, list) and v: - item_tp = _resolve_list_item_type(hints[f.name]) - if item_tp is not None and _is_dataclass_type(item_tp): - v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v] - elif isinstance(v, str): - hint = hints.get(f.name) - raw_hint = hint - if get_origin(hint) is not None: - for a in get_args(hint): - if a is not type(None): - raw_hint = a - break - if raw_hint is datetime: - v = datetime.fromisoformat(v) - kwargs[k] = v + kwargs[k] = _deserialize_instance(hints[f.name], v) return cls(**kwargs) +_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { +} + + +def deserialize(cls: Type[T], data: dict) -> T: + """Create a typed instance from a dict, recursively deserializing nested values.""" + return _deserialize_instance(cls, data) + def _strip_nones(val: object) -> object: if isinstance(val, dict): diff --git a/packages/generator/tests/crud/snapshots/ts/client.ts b/packages/generator/tests/crud/snapshots/ts/client.ts index ce015efd..ea5659f7 100644 --- a/packages/generator/tests/crud/snapshots/ts/client.ts +++ b/packages/generator/tests/crud/snapshots/ts/client.ts @@ -7,7 +7,7 @@ import { ChatCreateRequest, ChatUpdateRequest, } from "./types.js"; -import { deserialize, serialize, fetchWithRetry } from "./utils.js"; +import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js"; export class ChatsService { async listChats(params?: ListChatsParams): Promise { @@ -90,7 +90,7 @@ export class ChatsServiceImpl extends ChatsService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Chat; + return deserializeType("Chat", body.data) as Chat; case 401: throw new OAuthError(body.error); default: @@ -102,12 +102,12 @@ export class ChatsServiceImpl extends ChatsService { const response = await fetchWithRetry(`${this.baseUrl}/chats`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("ChatCreateRequest", request)), }); const body = await response.json(); switch (response.status) { case 201: - return deserialize(body.data) as Chat; + return deserializeType("Chat", body.data) as Chat; case 401: throw new OAuthError(body.error); default: @@ -119,12 +119,12 @@ export class ChatsServiceImpl extends ChatsService { const response = await fetchWithRetry(`${this.baseUrl}/chats/${id}`, { method: "PUT", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("ChatUpdateRequest", request)), }); const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Chat; + return deserializeType("Chat", body.data) as Chat; case 401: throw new OAuthError(body.error); default: diff --git a/packages/generator/tests/crud/snapshots/ts/utils.ts b/packages/generator/tests/crud/snapshots/ts/utils.ts index a471ae4d..35778d63 100644 --- a/packages/generator/tests/crud/snapshots/ts/utils.ts +++ b/packages/generator/tests/crud/snapshots/ts/utils.ts @@ -10,6 +10,32 @@ function camelToSnake(str: string): string { .toLowerCase(); } +function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj); +} + +function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj); +} + +function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)])); + } + return deserialize(obj); +} + +function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, mapValue(v)]), + ); + } + return serialize(obj); +} + export function deserialize(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(deserialize); if (obj !== null && typeof obj === "object") { @@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown { return obj; } +const TYPE_DESERIALIZERS: Record unknown> = { +}; + +export function deserializeType(type: string, obj: unknown): unknown { + return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj); +} + +export function serializeType(_type: string, obj: unknown): unknown { + return serialize(obj); +} + const MAX_RETRIES = 3; const RETRYABLE_5XX = new Set([500, 502, 503, 504]); diff --git a/packages/generator/tests/date-format/snapshots/py/utils.py b/packages/generator/tests/date-format/snapshots/py/utils.py index 28f6ac8b..a42b76a5 100644 --- a/packages/generator/tests/date-format/snapshots/py/utils.py +++ b/packages/generator/tests/date-format/snapshots/py/utils.py @@ -4,18 +4,18 @@ import keyword from dataclasses import asdict, fields from datetime import datetime -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints import httpx T = TypeVar("T") -def _is_dataclass_type(tp: type) -> bool: +def _is_dataclass_type(tp: object) -> bool: return isinstance(tp, type) and dataclasses.is_dataclass(tp) -def _resolve_type(tp: type) -> type | None: +def _resolve_type(tp: object) -> type | None: """Extract a concrete dataclass type from Optional[X] or X | None.""" origin = get_origin(tp) if origin is list: @@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None: return None -def _resolve_list_item_type(tp: type) -> type | None: +def _resolve_list_item_type(tp: object) -> object | None: """Extract the item type from list[X].""" origin = get_origin(tp) if origin is list: @@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None: return None -def deserialize(cls: Type[T], data: dict) -> T: - """Create a dataclass instance from a dict, recursively deserializing nested dataclasses.""" +CustomUnionDeserializer = Callable[[dict], object] + + +def _deserialize_instance(tp: object, value: object) -> object: + custom = _CUSTOM_UNION_DESERIALIZERS.get(tp) + if custom is not None and isinstance(value, dict): + return custom(value) + if isinstance(value, dict): + nested = _resolve_type(tp) + if nested is not None: + return _deserialize_dataclass(nested, value) + if isinstance(value, list): + item_tp = _resolve_list_item_type(tp) + if item_tp is not None: + return [_deserialize_instance(item_tp, item) for item in value] + if isinstance(value, str): + raw_tp = tp + if get_origin(tp) is not None: + for arg in get_args(tp): + if arg is not type(None): + raw_tp = arg + break + if raw_tp is datetime: + return datetime.fromisoformat(value) + return value + + +def _deserialize_dataclass(cls: Type[T], data: dict) -> T: field_map = {f.name: f for f in fields(cls)} hints = get_type_hints(cls) norm = {k.replace("-", "_").lower(): v for k, v in data.items()} @@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T: if k not in field_map: continue f = field_map[k] - if isinstance(v, dict): - nested = _resolve_type(hints[f.name]) - if nested is not None: - v = deserialize(nested, v) - elif isinstance(v, list) and v: - item_tp = _resolve_list_item_type(hints[f.name]) - if item_tp is not None and _is_dataclass_type(item_tp): - v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v] - elif isinstance(v, str): - hint = hints.get(f.name) - raw_hint = hint - if get_origin(hint) is not None: - for a in get_args(hint): - if a is not type(None): - raw_hint = a - break - if raw_hint is datetime: - v = datetime.fromisoformat(v) - kwargs[k] = v + kwargs[k] = _deserialize_instance(hints[f.name], v) return cls(**kwargs) +_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { +} + + +def deserialize(cls: Type[T], data: dict) -> T: + """Create a typed instance from a dict, recursively deserializing nested values.""" + return _deserialize_instance(cls, data) + def _strip_nones(val: object) -> object: if isinstance(val, dict): diff --git a/packages/generator/tests/date-format/snapshots/ts/client.ts b/packages/generator/tests/date-format/snapshots/ts/client.ts index 74c535eb..31216410 100644 --- a/packages/generator/tests/date-format/snapshots/ts/client.ts +++ b/packages/generator/tests/date-format/snapshots/ts/client.ts @@ -5,7 +5,7 @@ import { ExportRequest, Export, } from "./types.js"; -import { deserialize, serialize, fetchWithRetry } from "./utils.js"; +import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js"; export class ExportService { async listEvents(params: ListEventsParams): Promise { @@ -47,12 +47,12 @@ export class ExportServiceImpl extends ExportService { const response = await fetchWithRetry(`${this.baseUrl}/exports`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("ExportRequest", request)), }); const body = await response.json(); switch (response.status) { case 201: - return deserialize(body.data) as Export; + return deserializeType("Export", body.data) as Export; case 401: throw new OAuthError(body.error); default: diff --git a/packages/generator/tests/date-format/snapshots/ts/utils.ts b/packages/generator/tests/date-format/snapshots/ts/utils.ts index a471ae4d..35778d63 100644 --- a/packages/generator/tests/date-format/snapshots/ts/utils.ts +++ b/packages/generator/tests/date-format/snapshots/ts/utils.ts @@ -10,6 +10,32 @@ function camelToSnake(str: string): string { .toLowerCase(); } +function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj); +} + +function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj); +} + +function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)])); + } + return deserialize(obj); +} + +function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, mapValue(v)]), + ); + } + return serialize(obj); +} + export function deserialize(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(deserialize); if (obj !== null && typeof obj === "object") { @@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown { return obj; } +const TYPE_DESERIALIZERS: Record unknown> = { +}; + +export function deserializeType(type: string, obj: unknown): unknown { + return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj); +} + +export function serializeType(_type: string, obj: unknown): unknown { + return serialize(obj); +} + const MAX_RETRIES = 3; const RETRYABLE_5XX = new Set([500, 502, 503, 504]); diff --git a/packages/generator/tests/edge-cases/snapshots/cs/Models.cs b/packages/generator/tests/edge-cases/snapshots/cs/Models.cs index fa92f6e9..94676096 100644 --- a/packages/generator/tests/edge-cases/snapshots/cs/Models.cs +++ b/packages/generator/tests/edge-cases/snapshots/cs/Models.cs @@ -89,6 +89,7 @@ public abstract class NotificationUnion public class MessageNotification : NotificationUnion { + [JsonPropertyName("kind")] public override string Kind => "message"; [JsonPropertyName("text")] public string Text { get; set; } = default!; @@ -96,6 +97,7 @@ public class MessageNotification : NotificationUnion public class ReactionNotification : NotificationUnion { + [JsonPropertyName("kind")] public override string Kind => "message"; [JsonPropertyName("emoji")] public string Emoji { get; set; } = default!; diff --git a/packages/generator/tests/edge-cases/snapshots/py/utils.py b/packages/generator/tests/edge-cases/snapshots/py/utils.py index 28f6ac8b..a42b76a5 100644 --- a/packages/generator/tests/edge-cases/snapshots/py/utils.py +++ b/packages/generator/tests/edge-cases/snapshots/py/utils.py @@ -4,18 +4,18 @@ import keyword from dataclasses import asdict, fields from datetime import datetime -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints import httpx T = TypeVar("T") -def _is_dataclass_type(tp: type) -> bool: +def _is_dataclass_type(tp: object) -> bool: return isinstance(tp, type) and dataclasses.is_dataclass(tp) -def _resolve_type(tp: type) -> type | None: +def _resolve_type(tp: object) -> type | None: """Extract a concrete dataclass type from Optional[X] or X | None.""" origin = get_origin(tp) if origin is list: @@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None: return None -def _resolve_list_item_type(tp: type) -> type | None: +def _resolve_list_item_type(tp: object) -> object | None: """Extract the item type from list[X].""" origin = get_origin(tp) if origin is list: @@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None: return None -def deserialize(cls: Type[T], data: dict) -> T: - """Create a dataclass instance from a dict, recursively deserializing nested dataclasses.""" +CustomUnionDeserializer = Callable[[dict], object] + + +def _deserialize_instance(tp: object, value: object) -> object: + custom = _CUSTOM_UNION_DESERIALIZERS.get(tp) + if custom is not None and isinstance(value, dict): + return custom(value) + if isinstance(value, dict): + nested = _resolve_type(tp) + if nested is not None: + return _deserialize_dataclass(nested, value) + if isinstance(value, list): + item_tp = _resolve_list_item_type(tp) + if item_tp is not None: + return [_deserialize_instance(item_tp, item) for item in value] + if isinstance(value, str): + raw_tp = tp + if get_origin(tp) is not None: + for arg in get_args(tp): + if arg is not type(None): + raw_tp = arg + break + if raw_tp is datetime: + return datetime.fromisoformat(value) + return value + + +def _deserialize_dataclass(cls: Type[T], data: dict) -> T: field_map = {f.name: f for f in fields(cls)} hints = get_type_hints(cls) norm = {k.replace("-", "_").lower(): v for k, v in data.items()} @@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T: if k not in field_map: continue f = field_map[k] - if isinstance(v, dict): - nested = _resolve_type(hints[f.name]) - if nested is not None: - v = deserialize(nested, v) - elif isinstance(v, list) and v: - item_tp = _resolve_list_item_type(hints[f.name]) - if item_tp is not None and _is_dataclass_type(item_tp): - v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v] - elif isinstance(v, str): - hint = hints.get(f.name) - raw_hint = hint - if get_origin(hint) is not None: - for a in get_args(hint): - if a is not type(None): - raw_hint = a - break - if raw_hint is datetime: - v = datetime.fromisoformat(v) - kwargs[k] = v + kwargs[k] = _deserialize_instance(hints[f.name], v) return cls(**kwargs) +_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { +} + + +def deserialize(cls: Type[T], data: dict) -> T: + """Create a typed instance from a dict, recursively deserializing nested values.""" + return _deserialize_instance(cls, data) + def _strip_nones(val: object) -> object: if isinstance(val, dict): diff --git a/packages/generator/tests/edge-cases/snapshots/ts/client.ts b/packages/generator/tests/edge-cases/snapshots/ts/client.ts index 769ad584..c7ea0b57 100644 --- a/packages/generator/tests/edge-cases/snapshots/ts/client.ts +++ b/packages/generator/tests/edge-cases/snapshots/ts/client.ts @@ -5,7 +5,7 @@ import { Event, UploadRequest, } from "./types.js"; -import { deserialize, fetchWithRetry } from "./utils.js"; +import { deserialize, deserializeType, fetchWithRetry } from "./utils.js"; export class EventsService { async listEvents(params?: ListEventsParams): Promise { @@ -54,7 +54,7 @@ export class EventsServiceImpl extends EventsService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Event; + return deserializeType("Event", body.data) as Event; default: throw new Error(`HTTP ${response.status}: ${JSON.stringify(body)}`); } diff --git a/packages/generator/tests/edge-cases/snapshots/ts/utils.ts b/packages/generator/tests/edge-cases/snapshots/ts/utils.ts index a471ae4d..35778d63 100644 --- a/packages/generator/tests/edge-cases/snapshots/ts/utils.ts +++ b/packages/generator/tests/edge-cases/snapshots/ts/utils.ts @@ -10,6 +10,32 @@ function camelToSnake(str: string): string { .toLowerCase(); } +function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj); +} + +function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj); +} + +function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)])); + } + return deserialize(obj); +} + +function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, mapValue(v)]), + ); + } + return serialize(obj); +} + export function deserialize(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(deserialize); if (obj !== null && typeof obj === "object") { @@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown { return obj; } +const TYPE_DESERIALIZERS: Record unknown> = { +}; + +export function deserializeType(type: string, obj: unknown): unknown { + return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj); +} + +export function serializeType(_type: string, obj: unknown): unknown { + return serialize(obj); +} + const MAX_RETRIES = 3; const RETRYABLE_5XX = new Set([500, 502, 503, 504]); diff --git a/packages/generator/tests/multi-path-params/snapshots/py/utils.py b/packages/generator/tests/multi-path-params/snapshots/py/utils.py index 28f6ac8b..a42b76a5 100644 --- a/packages/generator/tests/multi-path-params/snapshots/py/utils.py +++ b/packages/generator/tests/multi-path-params/snapshots/py/utils.py @@ -4,18 +4,18 @@ import keyword from dataclasses import asdict, fields from datetime import datetime -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints import httpx T = TypeVar("T") -def _is_dataclass_type(tp: type) -> bool: +def _is_dataclass_type(tp: object) -> bool: return isinstance(tp, type) and dataclasses.is_dataclass(tp) -def _resolve_type(tp: type) -> type | None: +def _resolve_type(tp: object) -> type | None: """Extract a concrete dataclass type from Optional[X] or X | None.""" origin = get_origin(tp) if origin is list: @@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None: return None -def _resolve_list_item_type(tp: type) -> type | None: +def _resolve_list_item_type(tp: object) -> object | None: """Extract the item type from list[X].""" origin = get_origin(tp) if origin is list: @@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None: return None -def deserialize(cls: Type[T], data: dict) -> T: - """Create a dataclass instance from a dict, recursively deserializing nested dataclasses.""" +CustomUnionDeserializer = Callable[[dict], object] + + +def _deserialize_instance(tp: object, value: object) -> object: + custom = _CUSTOM_UNION_DESERIALIZERS.get(tp) + if custom is not None and isinstance(value, dict): + return custom(value) + if isinstance(value, dict): + nested = _resolve_type(tp) + if nested is not None: + return _deserialize_dataclass(nested, value) + if isinstance(value, list): + item_tp = _resolve_list_item_type(tp) + if item_tp is not None: + return [_deserialize_instance(item_tp, item) for item in value] + if isinstance(value, str): + raw_tp = tp + if get_origin(tp) is not None: + for arg in get_args(tp): + if arg is not type(None): + raw_tp = arg + break + if raw_tp is datetime: + return datetime.fromisoformat(value) + return value + + +def _deserialize_dataclass(cls: Type[T], data: dict) -> T: field_map = {f.name: f for f in fields(cls)} hints = get_type_hints(cls) norm = {k.replace("-", "_").lower(): v for k, v in data.items()} @@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T: if k not in field_map: continue f = field_map[k] - if isinstance(v, dict): - nested = _resolve_type(hints[f.name]) - if nested is not None: - v = deserialize(nested, v) - elif isinstance(v, list) and v: - item_tp = _resolve_list_item_type(hints[f.name]) - if item_tp is not None and _is_dataclass_type(item_tp): - v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v] - elif isinstance(v, str): - hint = hints.get(f.name) - raw_hint = hint - if get_origin(hint) is not None: - for a in get_args(hint): - if a is not type(None): - raw_hint = a - break - if raw_hint is datetime: - v = datetime.fromisoformat(v) - kwargs[k] = v + kwargs[k] = _deserialize_instance(hints[f.name], v) return cls(**kwargs) +_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { +} + + +def deserialize(cls: Type[T], data: dict) -> T: + """Create a typed instance from a dict, recursively deserializing nested values.""" + return _deserialize_instance(cls, data) + def _strip_nones(val: object) -> object: if isinstance(val, dict): diff --git a/packages/generator/tests/multi-path-params/snapshots/ts/client.ts b/packages/generator/tests/multi-path-params/snapshots/ts/client.ts index b510ce5d..a147b3e6 100644 --- a/packages/generator/tests/multi-path-params/snapshots/ts/client.ts +++ b/packages/generator/tests/multi-path-params/snapshots/ts/client.ts @@ -1,5 +1,5 @@ import { Task, TaskUpdateRequest } from "./types.js"; -import { deserialize, serialize, fetchWithRetry } from "./utils.js"; +import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js"; export class TasksService { async getTask(projectId: number, taskId: number): Promise { @@ -30,7 +30,7 @@ export class TasksServiceImpl extends TasksService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Task; + return deserializeType("Task", body.data) as Task; default: throw new Error(`HTTP ${response.status}: ${JSON.stringify(body)}`); } @@ -40,12 +40,12 @@ export class TasksServiceImpl extends TasksService { const response = await fetchWithRetry(`${this.baseUrl}/projects/${projectId}/tasks/${taskId}`, { method: "PUT", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("TaskUpdateRequest", request)), }); const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Task; + return deserializeType("Task", body.data) as Task; default: throw new Error(`HTTP ${response.status}: ${JSON.stringify(body)}`); } diff --git a/packages/generator/tests/multi-path-params/snapshots/ts/utils.ts b/packages/generator/tests/multi-path-params/snapshots/ts/utils.ts index a471ae4d..35778d63 100644 --- a/packages/generator/tests/multi-path-params/snapshots/ts/utils.ts +++ b/packages/generator/tests/multi-path-params/snapshots/ts/utils.ts @@ -10,6 +10,32 @@ function camelToSnake(str: string): string { .toLowerCase(); } +function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj); +} + +function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj); +} + +function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)])); + } + return deserialize(obj); +} + +function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, mapValue(v)]), + ); + } + return serialize(obj); +} + export function deserialize(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(deserialize); if (obj !== null && typeof obj === "object") { @@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown { return obj; } +const TYPE_DESERIALIZERS: Record unknown> = { +}; + +export function deserializeType(type: string, obj: unknown): unknown { + return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj); +} + +export function serializeType(_type: string, obj: unknown): unknown { + return serialize(obj); +} + const MAX_RETRIES = 3; const RETRYABLE_5XX = new Set([500, 502, 503, 504]); diff --git a/packages/generator/tests/oneof/snapshots/cs/Models.cs b/packages/generator/tests/oneof/snapshots/cs/Models.cs index 6d73667d..ccfd5c3e 100644 --- a/packages/generator/tests/oneof/snapshots/cs/Models.cs +++ b/packages/generator/tests/oneof/snapshots/cs/Models.cs @@ -20,6 +20,7 @@ public abstract class ContentBlock public class TextContent : ContentBlock { + [JsonPropertyName("kind")] public override string Kind => "text"; [JsonPropertyName("text")] public string Text { get; set; } = default!; @@ -27,6 +28,7 @@ public class TextContent : ContentBlock public class ImageContent : ContentBlock { + [JsonPropertyName("kind")] public override string Kind => "image"; [JsonPropertyName("url")] public string Url { get; set; } = default!; @@ -36,6 +38,7 @@ public class ImageContent : ContentBlock public class VideoContent : ContentBlock { + [JsonPropertyName("kind")] public override string Kind => "video"; [JsonPropertyName("url")] public string Url { get; set; } = default!; diff --git a/packages/generator/tests/patch/snapshots/py/utils.py b/packages/generator/tests/patch/snapshots/py/utils.py index 28f6ac8b..a42b76a5 100644 --- a/packages/generator/tests/patch/snapshots/py/utils.py +++ b/packages/generator/tests/patch/snapshots/py/utils.py @@ -4,18 +4,18 @@ import keyword from dataclasses import asdict, fields from datetime import datetime -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints import httpx T = TypeVar("T") -def _is_dataclass_type(tp: type) -> bool: +def _is_dataclass_type(tp: object) -> bool: return isinstance(tp, type) and dataclasses.is_dataclass(tp) -def _resolve_type(tp: type) -> type | None: +def _resolve_type(tp: object) -> type | None: """Extract a concrete dataclass type from Optional[X] or X | None.""" origin = get_origin(tp) if origin is list: @@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None: return None -def _resolve_list_item_type(tp: type) -> type | None: +def _resolve_list_item_type(tp: object) -> object | None: """Extract the item type from list[X].""" origin = get_origin(tp) if origin is list: @@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None: return None -def deserialize(cls: Type[T], data: dict) -> T: - """Create a dataclass instance from a dict, recursively deserializing nested dataclasses.""" +CustomUnionDeserializer = Callable[[dict], object] + + +def _deserialize_instance(tp: object, value: object) -> object: + custom = _CUSTOM_UNION_DESERIALIZERS.get(tp) + if custom is not None and isinstance(value, dict): + return custom(value) + if isinstance(value, dict): + nested = _resolve_type(tp) + if nested is not None: + return _deserialize_dataclass(nested, value) + if isinstance(value, list): + item_tp = _resolve_list_item_type(tp) + if item_tp is not None: + return [_deserialize_instance(item_tp, item) for item in value] + if isinstance(value, str): + raw_tp = tp + if get_origin(tp) is not None: + for arg in get_args(tp): + if arg is not type(None): + raw_tp = arg + break + if raw_tp is datetime: + return datetime.fromisoformat(value) + return value + + +def _deserialize_dataclass(cls: Type[T], data: dict) -> T: field_map = {f.name: f for f in fields(cls)} hints = get_type_hints(cls) norm = {k.replace("-", "_").lower(): v for k, v in data.items()} @@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T: if k not in field_map: continue f = field_map[k] - if isinstance(v, dict): - nested = _resolve_type(hints[f.name]) - if nested is not None: - v = deserialize(nested, v) - elif isinstance(v, list) and v: - item_tp = _resolve_list_item_type(hints[f.name]) - if item_tp is not None and _is_dataclass_type(item_tp): - v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v] - elif isinstance(v, str): - hint = hints.get(f.name) - raw_hint = hint - if get_origin(hint) is not None: - for a in get_args(hint): - if a is not type(None): - raw_hint = a - break - if raw_hint is datetime: - v = datetime.fromisoformat(v) - kwargs[k] = v + kwargs[k] = _deserialize_instance(hints[f.name], v) return cls(**kwargs) +_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { +} + + +def deserialize(cls: Type[T], data: dict) -> T: + """Create a typed instance from a dict, recursively deserializing nested values.""" + return _deserialize_instance(cls, data) + def _strip_nones(val: object) -> object: if isinstance(val, dict): diff --git a/packages/generator/tests/patch/snapshots/ts/client.ts b/packages/generator/tests/patch/snapshots/ts/client.ts index 754d99e7..8b732806 100644 --- a/packages/generator/tests/patch/snapshots/ts/client.ts +++ b/packages/generator/tests/patch/snapshots/ts/client.ts @@ -1,5 +1,5 @@ import { ItemPatchRequest, Item, ApiError } from "./types.js"; -import { deserialize, serialize, fetchWithRetry } from "./utils.js"; +import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js"; export class ItemsService { async patchItem(id: number, request: ItemPatchRequest): Promise { @@ -19,12 +19,12 @@ export class ItemsServiceImpl extends ItemsService { const response = await fetchWithRetry(`${this.baseUrl}/items/${id}`, { method: "PATCH", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("ItemPatchRequest", request)), }); const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Item; + return deserializeType("Item", body.data) as Item; default: throw new ApiError(body.errors); } diff --git a/packages/generator/tests/patch/snapshots/ts/utils.ts b/packages/generator/tests/patch/snapshots/ts/utils.ts index a471ae4d..35778d63 100644 --- a/packages/generator/tests/patch/snapshots/ts/utils.ts +++ b/packages/generator/tests/patch/snapshots/ts/utils.ts @@ -10,6 +10,32 @@ function camelToSnake(str: string): string { .toLowerCase(); } +function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj); +} + +function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj); +} + +function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)])); + } + return deserialize(obj); +} + +function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, mapValue(v)]), + ); + } + return serialize(obj); +} + export function deserialize(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(deserialize); if (obj !== null && typeof obj === "object") { @@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown { return obj; } +const TYPE_DESERIALIZERS: Record unknown> = { +}; + +export function deserializeType(type: string, obj: unknown): unknown { + return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj); +} + +export function serializeType(_type: string, obj: unknown): unknown { + return serialize(obj); +} + const MAX_RETRIES = 3; const RETRYABLE_5XX = new Set([500, 502, 503, 504]); diff --git a/packages/generator/tests/record/snapshots/py/utils.py b/packages/generator/tests/record/snapshots/py/utils.py index 28f6ac8b..a42b76a5 100644 --- a/packages/generator/tests/record/snapshots/py/utils.py +++ b/packages/generator/tests/record/snapshots/py/utils.py @@ -4,18 +4,18 @@ import keyword from dataclasses import asdict, fields from datetime import datetime -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints import httpx T = TypeVar("T") -def _is_dataclass_type(tp: type) -> bool: +def _is_dataclass_type(tp: object) -> bool: return isinstance(tp, type) and dataclasses.is_dataclass(tp) -def _resolve_type(tp: type) -> type | None: +def _resolve_type(tp: object) -> type | None: """Extract a concrete dataclass type from Optional[X] or X | None.""" origin = get_origin(tp) if origin is list: @@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None: return None -def _resolve_list_item_type(tp: type) -> type | None: +def _resolve_list_item_type(tp: object) -> object | None: """Extract the item type from list[X].""" origin = get_origin(tp) if origin is list: @@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None: return None -def deserialize(cls: Type[T], data: dict) -> T: - """Create a dataclass instance from a dict, recursively deserializing nested dataclasses.""" +CustomUnionDeserializer = Callable[[dict], object] + + +def _deserialize_instance(tp: object, value: object) -> object: + custom = _CUSTOM_UNION_DESERIALIZERS.get(tp) + if custom is not None and isinstance(value, dict): + return custom(value) + if isinstance(value, dict): + nested = _resolve_type(tp) + if nested is not None: + return _deserialize_dataclass(nested, value) + if isinstance(value, list): + item_tp = _resolve_list_item_type(tp) + if item_tp is not None: + return [_deserialize_instance(item_tp, item) for item in value] + if isinstance(value, str): + raw_tp = tp + if get_origin(tp) is not None: + for arg in get_args(tp): + if arg is not type(None): + raw_tp = arg + break + if raw_tp is datetime: + return datetime.fromisoformat(value) + return value + + +def _deserialize_dataclass(cls: Type[T], data: dict) -> T: field_map = {f.name: f for f in fields(cls)} hints = get_type_hints(cls) norm = {k.replace("-", "_").lower(): v for k, v in data.items()} @@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T: if k not in field_map: continue f = field_map[k] - if isinstance(v, dict): - nested = _resolve_type(hints[f.name]) - if nested is not None: - v = deserialize(nested, v) - elif isinstance(v, list) and v: - item_tp = _resolve_list_item_type(hints[f.name]) - if item_tp is not None and _is_dataclass_type(item_tp): - v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v] - elif isinstance(v, str): - hint = hints.get(f.name) - raw_hint = hint - if get_origin(hint) is not None: - for a in get_args(hint): - if a is not type(None): - raw_hint = a - break - if raw_hint is datetime: - v = datetime.fromisoformat(v) - kwargs[k] = v + kwargs[k] = _deserialize_instance(hints[f.name], v) return cls(**kwargs) +_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { +} + + +def deserialize(cls: Type[T], data: dict) -> T: + """Create a typed instance from a dict, recursively deserializing nested values.""" + return _deserialize_instance(cls, data) + def _strip_nones(val: object) -> object: if isinstance(val, dict): diff --git a/packages/generator/tests/record/snapshots/ts/client.ts b/packages/generator/tests/record/snapshots/ts/client.ts index c518d3a0..7358746a 100644 --- a/packages/generator/tests/record/snapshots/ts/client.ts +++ b/packages/generator/tests/record/snapshots/ts/client.ts @@ -1,5 +1,5 @@ import { LinkPreviewsRequest, OAuthError, ApiError } from "./types.js"; -import { serialize, fetchWithRetry } from "./utils.js"; +import { serializeType, fetchWithRetry } from "./utils.js"; export class LinkPreviewsService { async createLinkPreviews(id: number, request: LinkPreviewsRequest): Promise { @@ -19,7 +19,7 @@ export class LinkPreviewsServiceImpl extends LinkPreviewsService { const response = await fetchWithRetry(`${this.baseUrl}/messages/${id}/link_previews`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("LinkPreviewsRequest", request)), }); switch (response.status) { case 201: diff --git a/packages/generator/tests/record/snapshots/ts/utils.ts b/packages/generator/tests/record/snapshots/ts/utils.ts index a8d35198..37cfe567 100644 --- a/packages/generator/tests/record/snapshots/ts/utils.ts +++ b/packages/generator/tests/record/snapshots/ts/utils.ts @@ -12,21 +12,27 @@ function camelToSnake(str: string): string { const RECORD_KEYS = new Set(["link_previews", "linkPreviews"]); -function deserializeRecord(obj: unknown): unknown { +function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj); +} + +function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj); +} + +function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { - return Object.fromEntries( - Object.entries(obj).map(([k, v]) => [k, deserialize(v)]), - ); + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)])); } return deserialize(obj); } -function serializeRecord(obj: unknown): unknown { +function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { return Object.fromEntries( Object.entries(obj) .filter(([, v]) => v !== undefined) - .map(([k, v]) => [k, serialize(v)]), + .map(([k, v]) => [k, mapValue(v)]), ); } return serialize(obj); @@ -38,7 +44,7 @@ export function deserialize(obj: unknown): unknown { return Object.fromEntries( Object.entries(obj).map(([k, v]) => { const ck = snakeToCamel(k); - return [ck, RECORD_KEYS.has(ck) ? deserializeRecord(v) : deserialize(v)]; + return [ck, RECORD_KEYS.has(ck) ? deserializeRecordWith(v, deserialize) : deserialize(v)]; }), ); } @@ -51,14 +57,23 @@ export function serialize(obj: unknown): unknown { return Object.fromEntries( Object.entries(obj) .filter(([, v]) => v !== undefined) - .map(([k, v]) => { - return [camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecord(v) : serialize(v)]; - }), + .map(([k, v]) => [camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecordWith(v, serialize) : serialize(v)]), ); } return obj; } +const TYPE_DESERIALIZERS: Record unknown> = { +}; + +export function deserializeType(type: string, obj: unknown): unknown { + return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj); +} + +export function serializeType(_type: string, obj: unknown): unknown { + return serialize(obj); +} + const MAX_RETRIES = 3; const RETRYABLE_5XX = new Set([500, 502, 503, 504]); diff --git a/packages/generator/tests/redirect/snapshots/py/utils.py b/packages/generator/tests/redirect/snapshots/py/utils.py index 28f6ac8b..a42b76a5 100644 --- a/packages/generator/tests/redirect/snapshots/py/utils.py +++ b/packages/generator/tests/redirect/snapshots/py/utils.py @@ -4,18 +4,18 @@ import keyword from dataclasses import asdict, fields from datetime import datetime -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints import httpx T = TypeVar("T") -def _is_dataclass_type(tp: type) -> bool: +def _is_dataclass_type(tp: object) -> bool: return isinstance(tp, type) and dataclasses.is_dataclass(tp) -def _resolve_type(tp: type) -> type | None: +def _resolve_type(tp: object) -> type | None: """Extract a concrete dataclass type from Optional[X] or X | None.""" origin = get_origin(tp) if origin is list: @@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None: return None -def _resolve_list_item_type(tp: type) -> type | None: +def _resolve_list_item_type(tp: object) -> object | None: """Extract the item type from list[X].""" origin = get_origin(tp) if origin is list: @@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None: return None -def deserialize(cls: Type[T], data: dict) -> T: - """Create a dataclass instance from a dict, recursively deserializing nested dataclasses.""" +CustomUnionDeserializer = Callable[[dict], object] + + +def _deserialize_instance(tp: object, value: object) -> object: + custom = _CUSTOM_UNION_DESERIALIZERS.get(tp) + if custom is not None and isinstance(value, dict): + return custom(value) + if isinstance(value, dict): + nested = _resolve_type(tp) + if nested is not None: + return _deserialize_dataclass(nested, value) + if isinstance(value, list): + item_tp = _resolve_list_item_type(tp) + if item_tp is not None: + return [_deserialize_instance(item_tp, item) for item in value] + if isinstance(value, str): + raw_tp = tp + if get_origin(tp) is not None: + for arg in get_args(tp): + if arg is not type(None): + raw_tp = arg + break + if raw_tp is datetime: + return datetime.fromisoformat(value) + return value + + +def _deserialize_dataclass(cls: Type[T], data: dict) -> T: field_map = {f.name: f for f in fields(cls)} hints = get_type_hints(cls) norm = {k.replace("-", "_").lower(): v for k, v in data.items()} @@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T: if k not in field_map: continue f = field_map[k] - if isinstance(v, dict): - nested = _resolve_type(hints[f.name]) - if nested is not None: - v = deserialize(nested, v) - elif isinstance(v, list) and v: - item_tp = _resolve_list_item_type(hints[f.name]) - if item_tp is not None and _is_dataclass_type(item_tp): - v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v] - elif isinstance(v, str): - hint = hints.get(f.name) - raw_hint = hint - if get_origin(hint) is not None: - for a in get_args(hint): - if a is not type(None): - raw_hint = a - break - if raw_hint is datetime: - v = datetime.fromisoformat(v) - kwargs[k] = v + kwargs[k] = _deserialize_instance(hints[f.name], v) return cls(**kwargs) +_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { +} + + +def deserialize(cls: Type[T], data: dict) -> T: + """Create a typed instance from a dict, recursively deserializing nested values.""" + return _deserialize_instance(cls, data) + def _strip_nones(val: object) -> object: if isinstance(val, dict): diff --git a/packages/generator/tests/redirect/snapshots/ts/utils.ts b/packages/generator/tests/redirect/snapshots/ts/utils.ts index a471ae4d..35778d63 100644 --- a/packages/generator/tests/redirect/snapshots/ts/utils.ts +++ b/packages/generator/tests/redirect/snapshots/ts/utils.ts @@ -10,6 +10,32 @@ function camelToSnake(str: string): string { .toLowerCase(); } +function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj); +} + +function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj); +} + +function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)])); + } + return deserialize(obj); +} + +function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, mapValue(v)]), + ); + } + return serialize(obj); +} + export function deserialize(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(deserialize); if (obj !== null && typeof obj === "object") { @@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown { return obj; } +const TYPE_DESERIALIZERS: Record unknown> = { +}; + +export function deserializeType(type: string, obj: unknown): unknown { + return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj); +} + +export function serializeType(_type: string, obj: unknown): unknown { + return serialize(obj); +} + const MAX_RETRIES = 3; const RETRYABLE_5XX = new Set([500, 502, 503, 504]); diff --git a/packages/generator/tests/search/snapshots/py/utils.py b/packages/generator/tests/search/snapshots/py/utils.py index 28f6ac8b..a42b76a5 100644 --- a/packages/generator/tests/search/snapshots/py/utils.py +++ b/packages/generator/tests/search/snapshots/py/utils.py @@ -4,18 +4,18 @@ import keyword from dataclasses import asdict, fields from datetime import datetime -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints import httpx T = TypeVar("T") -def _is_dataclass_type(tp: type) -> bool: +def _is_dataclass_type(tp: object) -> bool: return isinstance(tp, type) and dataclasses.is_dataclass(tp) -def _resolve_type(tp: type) -> type | None: +def _resolve_type(tp: object) -> type | None: """Extract a concrete dataclass type from Optional[X] or X | None.""" origin = get_origin(tp) if origin is list: @@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None: return None -def _resolve_list_item_type(tp: type) -> type | None: +def _resolve_list_item_type(tp: object) -> object | None: """Extract the item type from list[X].""" origin = get_origin(tp) if origin is list: @@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None: return None -def deserialize(cls: Type[T], data: dict) -> T: - """Create a dataclass instance from a dict, recursively deserializing nested dataclasses.""" +CustomUnionDeserializer = Callable[[dict], object] + + +def _deserialize_instance(tp: object, value: object) -> object: + custom = _CUSTOM_UNION_DESERIALIZERS.get(tp) + if custom is not None and isinstance(value, dict): + return custom(value) + if isinstance(value, dict): + nested = _resolve_type(tp) + if nested is not None: + return _deserialize_dataclass(nested, value) + if isinstance(value, list): + item_tp = _resolve_list_item_type(tp) + if item_tp is not None: + return [_deserialize_instance(item_tp, item) for item in value] + if isinstance(value, str): + raw_tp = tp + if get_origin(tp) is not None: + for arg in get_args(tp): + if arg is not type(None): + raw_tp = arg + break + if raw_tp is datetime: + return datetime.fromisoformat(value) + return value + + +def _deserialize_dataclass(cls: Type[T], data: dict) -> T: field_map = {f.name: f for f in fields(cls)} hints = get_type_hints(cls) norm = {k.replace("-", "_").lower(): v for k, v in data.items()} @@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T: if k not in field_map: continue f = field_map[k] - if isinstance(v, dict): - nested = _resolve_type(hints[f.name]) - if nested is not None: - v = deserialize(nested, v) - elif isinstance(v, list) and v: - item_tp = _resolve_list_item_type(hints[f.name]) - if item_tp is not None and _is_dataclass_type(item_tp): - v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v] - elif isinstance(v, str): - hint = hints.get(f.name) - raw_hint = hint - if get_origin(hint) is not None: - for a in get_args(hint): - if a is not type(None): - raw_hint = a - break - if raw_hint is datetime: - v = datetime.fromisoformat(v) - kwargs[k] = v + kwargs[k] = _deserialize_instance(hints[f.name], v) return cls(**kwargs) +_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { +} + + +def deserialize(cls: Type[T], data: dict) -> T: + """Create a typed instance from a dict, recursively deserializing nested values.""" + return _deserialize_instance(cls, data) + def _strip_nones(val: object) -> object: if isinstance(val, dict): diff --git a/packages/generator/tests/search/snapshots/ts/client.ts b/packages/generator/tests/search/snapshots/ts/client.ts index ae97ab5b..360a22e8 100644 --- a/packages/generator/tests/search/snapshots/ts/client.ts +++ b/packages/generator/tests/search/snapshots/ts/client.ts @@ -4,7 +4,7 @@ import { MessageSearchResult, OAuthError, } from "./types.js"; -import { deserialize, fetchWithRetry } from "./utils.js"; +import { deserialize, deserializeType, fetchWithRetry } from "./utils.js"; export class SearchService { async searchMessages(params: SearchMessagesParams): Promise { diff --git a/packages/generator/tests/search/snapshots/ts/utils.ts b/packages/generator/tests/search/snapshots/ts/utils.ts index a471ae4d..35778d63 100644 --- a/packages/generator/tests/search/snapshots/ts/utils.ts +++ b/packages/generator/tests/search/snapshots/ts/utils.ts @@ -10,6 +10,32 @@ function camelToSnake(str: string): string { .toLowerCase(); } +function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj); +} + +function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj); +} + +function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)])); + } + return deserialize(obj); +} + +function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, mapValue(v)]), + ); + } + return serialize(obj); +} + export function deserialize(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(deserialize); if (obj !== null && typeof obj === "object") { @@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown { return obj; } +const TYPE_DESERIALIZERS: Record unknown> = { +}; + +export function deserializeType(type: string, obj: unknown): unknown { + return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj); +} + +export function serializeType(_type: string, obj: unknown): unknown { + return serialize(obj); +} + const MAX_RETRIES = 3; const RETRYABLE_5XX = new Set([500, 502, 503, 504]); diff --git a/packages/generator/tests/unions/fixture.yaml b/packages/generator/tests/unions/fixture.yaml index 58c746bc..9fffb735 100644 --- a/packages/generator/tests/unions/fixture.yaml +++ b/packages/generator/tests/unions/fixture.yaml @@ -48,6 +48,7 @@ components: type: object required: - type + - event - url properties: type: @@ -57,6 +58,11 @@ components: description: Тип блока x-enum-descriptions: image: Для изображений всегда image + event: + type: string + enum: + - image_shared + description: Вторичный литеральный признак события url: type: string description: URL изображения diff --git a/packages/generator/tests/unions/snapshots/cs/Models.cs b/packages/generator/tests/unions/snapshots/cs/Models.cs index 4d2155b0..d3d65b8e 100644 --- a/packages/generator/tests/unions/snapshots/cs/Models.cs +++ b/packages/generator/tests/unions/snapshots/cs/Models.cs @@ -20,6 +20,7 @@ public abstract class ViewBlockUnion public class ViewBlockHeader : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "header"; [JsonPropertyName("text")] public string Text { get; set; } = default!; @@ -27,6 +28,7 @@ public class ViewBlockHeader : ViewBlockUnion public class ViewBlockPlainText : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "plain_text"; [JsonPropertyName("text")] public string Text { get; set; } = default!; @@ -34,7 +36,10 @@ public class ViewBlockPlainText : ViewBlockUnion public class ViewBlockImage : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "image"; + [JsonPropertyName("event")] + public string @Event => "image_shared"; [JsonPropertyName("url")] public string Url { get; set; } = default!; [JsonPropertyName("alt")] diff --git a/packages/generator/tests/unions/snapshots/go/types.go b/packages/generator/tests/unions/snapshots/go/types.go index a5979bbb..46151661 100644 --- a/packages/generator/tests/unions/snapshots/go/types.go +++ b/packages/generator/tests/unions/snapshots/go/types.go @@ -16,9 +16,10 @@ type ViewBlockPlainText struct { } type ViewBlockImage struct { - Type string `json:"type"` // always "image" - URL string `json:"url"` - Alt *string `json:"alt,omitempty"` + Type string `json:"type"` // always "image" + Event string `json:"event"` // always "image_shared" + URL string `json:"url"` + Alt *string `json:"alt,omitempty"` } type ViewBlockUnion struct { diff --git a/packages/generator/tests/unions/snapshots/kt/Models.kt b/packages/generator/tests/unions/snapshots/kt/Models.kt index d92bce0e..dcec774c 100644 --- a/packages/generator/tests/unions/snapshots/kt/Models.kt +++ b/packages/generator/tests/unions/snapshots/kt/Models.kt @@ -28,4 +28,6 @@ data class ViewBlockImage( override val type: String = "image", val url: String, val alt: String? = null, -) : ViewBlockUnion +) : ViewBlockUnion { + val event: String = "image_shared" +} diff --git a/packages/generator/tests/unions/snapshots/py/models.py b/packages/generator/tests/unions/snapshots/py/models.py index fddeff9d..cddcbca9 100644 --- a/packages/generator/tests/unions/snapshots/py/models.py +++ b/packages/generator/tests/unions/snapshots/py/models.py @@ -18,6 +18,7 @@ class ViewBlockPlainText: @dataclass class ViewBlockImage: type: str # literal "image" + event: str # literal "image_shared" url: str alt: str | None = None diff --git a/packages/generator/tests/unions/snapshots/swift/Models.swift b/packages/generator/tests/unions/snapshots/swift/Models.swift index 550584ca..1b5b8711 100644 --- a/packages/generator/tests/unions/snapshots/swift/Models.swift +++ b/packages/generator/tests/unions/snapshots/swift/Models.swift @@ -25,11 +25,13 @@ public struct ViewBlockPlainText: Codable { public struct ViewBlockImage: Codable { public let type: String + public let event: String public let url: String public let alt: String? - public init(type: String, url: String, alt: String? = nil) { + public init(type: String, event: String, url: String, alt: String? = nil) { self.type = type + self.event = event self.url = url self.alt = alt } diff --git a/packages/generator/tests/unions/snapshots/ts/types.ts b/packages/generator/tests/unions/snapshots/ts/types.ts index 08af7d7e..1c304cef 100644 --- a/packages/generator/tests/unions/snapshots/ts/types.ts +++ b/packages/generator/tests/unions/snapshots/ts/types.ts @@ -10,6 +10,7 @@ export interface ViewBlockPlainText { export interface ViewBlockImage { type: "image"; + event: "image_shared"; url: string; alt?: string; } diff --git a/packages/generator/tests/unwrap/snapshots/py/utils.py b/packages/generator/tests/unwrap/snapshots/py/utils.py index 28f6ac8b..a42b76a5 100644 --- a/packages/generator/tests/unwrap/snapshots/py/utils.py +++ b/packages/generator/tests/unwrap/snapshots/py/utils.py @@ -4,18 +4,18 @@ import keyword from dataclasses import asdict, fields from datetime import datetime -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints import httpx T = TypeVar("T") -def _is_dataclass_type(tp: type) -> bool: +def _is_dataclass_type(tp: object) -> bool: return isinstance(tp, type) and dataclasses.is_dataclass(tp) -def _resolve_type(tp: type) -> type | None: +def _resolve_type(tp: object) -> type | None: """Extract a concrete dataclass type from Optional[X] or X | None.""" origin = get_origin(tp) if origin is list: @@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None: return None -def _resolve_list_item_type(tp: type) -> type | None: +def _resolve_list_item_type(tp: object) -> object | None: """Extract the item type from list[X].""" origin = get_origin(tp) if origin is list: @@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None: return None -def deserialize(cls: Type[T], data: dict) -> T: - """Create a dataclass instance from a dict, recursively deserializing nested dataclasses.""" +CustomUnionDeserializer = Callable[[dict], object] + + +def _deserialize_instance(tp: object, value: object) -> object: + custom = _CUSTOM_UNION_DESERIALIZERS.get(tp) + if custom is not None and isinstance(value, dict): + return custom(value) + if isinstance(value, dict): + nested = _resolve_type(tp) + if nested is not None: + return _deserialize_dataclass(nested, value) + if isinstance(value, list): + item_tp = _resolve_list_item_type(tp) + if item_tp is not None: + return [_deserialize_instance(item_tp, item) for item in value] + if isinstance(value, str): + raw_tp = tp + if get_origin(tp) is not None: + for arg in get_args(tp): + if arg is not type(None): + raw_tp = arg + break + if raw_tp is datetime: + return datetime.fromisoformat(value) + return value + + +def _deserialize_dataclass(cls: Type[T], data: dict) -> T: field_map = {f.name: f for f in fields(cls)} hints = get_type_hints(cls) norm = {k.replace("-", "_").lower(): v for k, v in data.items()} @@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T: if k not in field_map: continue f = field_map[k] - if isinstance(v, dict): - nested = _resolve_type(hints[f.name]) - if nested is not None: - v = deserialize(nested, v) - elif isinstance(v, list) and v: - item_tp = _resolve_list_item_type(hints[f.name]) - if item_tp is not None and _is_dataclass_type(item_tp): - v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v] - elif isinstance(v, str): - hint = hints.get(f.name) - raw_hint = hint - if get_origin(hint) is not None: - for a in get_args(hint): - if a is not type(None): - raw_hint = a - break - if raw_hint is datetime: - v = datetime.fromisoformat(v) - kwargs[k] = v + kwargs[k] = _deserialize_instance(hints[f.name], v) return cls(**kwargs) +_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { +} + + +def deserialize(cls: Type[T], data: dict) -> T: + """Create a typed instance from a dict, recursively deserializing nested values.""" + return _deserialize_instance(cls, data) + def _strip_nones(val: object) -> object: if isinstance(val, dict): diff --git a/packages/generator/tests/unwrap/snapshots/ts/client.ts b/packages/generator/tests/unwrap/snapshots/ts/client.ts index adb4d058..0eb825c3 100644 --- a/packages/generator/tests/unwrap/snapshots/ts/client.ts +++ b/packages/generator/tests/unwrap/snapshots/ts/client.ts @@ -4,7 +4,7 @@ import { ChatCreateRequest, Chat, } from "./types.js"; -import { deserialize, serialize, fetchWithRetry } from "./utils.js"; +import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js"; export class MembersService { async addMembers(id: number, memberIds: number[]): Promise { @@ -59,12 +59,12 @@ export class ChatsServiceImpl extends ChatsService { const response = await fetchWithRetry(`${this.baseUrl}/chats`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("ChatCreateRequest", request)), }); const body = await response.json(); switch (response.status) { case 201: - return deserialize(body.data) as Chat; + return deserializeType("Chat", body.data) as Chat; case 401: throw new OAuthError(body.error); default: diff --git a/packages/generator/tests/unwrap/snapshots/ts/utils.ts b/packages/generator/tests/unwrap/snapshots/ts/utils.ts index a471ae4d..35778d63 100644 --- a/packages/generator/tests/unwrap/snapshots/ts/utils.ts +++ b/packages/generator/tests/unwrap/snapshots/ts/utils.ts @@ -10,6 +10,32 @@ function camelToSnake(str: string): string { .toLowerCase(); } +function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj); +} + +function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj); +} + +function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)])); + } + return deserialize(obj); +} + +function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, mapValue(v)]), + ); + } + return serialize(obj); +} + export function deserialize(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(deserialize); if (obj !== null && typeof obj === "object") { @@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown { return obj; } +const TYPE_DESERIALIZERS: Record unknown> = { +}; + +export function deserializeType(type: string, obj: unknown): unknown { + return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj); +} + +export function serializeType(_type: string, obj: unknown): unknown { + return serialize(obj); +} + const MAX_RETRIES = 3; const RETRYABLE_5XX = new Set([500, 502, 503, 504]); diff --git a/packages/generator/tests/upload/snapshots/py/utils.py b/packages/generator/tests/upload/snapshots/py/utils.py index 28f6ac8b..a42b76a5 100644 --- a/packages/generator/tests/upload/snapshots/py/utils.py +++ b/packages/generator/tests/upload/snapshots/py/utils.py @@ -4,18 +4,18 @@ import keyword from dataclasses import asdict, fields from datetime import datetime -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints import httpx T = TypeVar("T") -def _is_dataclass_type(tp: type) -> bool: +def _is_dataclass_type(tp: object) -> bool: return isinstance(tp, type) and dataclasses.is_dataclass(tp) -def _resolve_type(tp: type) -> type | None: +def _resolve_type(tp: object) -> type | None: """Extract a concrete dataclass type from Optional[X] or X | None.""" origin = get_origin(tp) if origin is list: @@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None: return None -def _resolve_list_item_type(tp: type) -> type | None: +def _resolve_list_item_type(tp: object) -> object | None: """Extract the item type from list[X].""" origin = get_origin(tp) if origin is list: @@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None: return None -def deserialize(cls: Type[T], data: dict) -> T: - """Create a dataclass instance from a dict, recursively deserializing nested dataclasses.""" +CustomUnionDeserializer = Callable[[dict], object] + + +def _deserialize_instance(tp: object, value: object) -> object: + custom = _CUSTOM_UNION_DESERIALIZERS.get(tp) + if custom is not None and isinstance(value, dict): + return custom(value) + if isinstance(value, dict): + nested = _resolve_type(tp) + if nested is not None: + return _deserialize_dataclass(nested, value) + if isinstance(value, list): + item_tp = _resolve_list_item_type(tp) + if item_tp is not None: + return [_deserialize_instance(item_tp, item) for item in value] + if isinstance(value, str): + raw_tp = tp + if get_origin(tp) is not None: + for arg in get_args(tp): + if arg is not type(None): + raw_tp = arg + break + if raw_tp is datetime: + return datetime.fromisoformat(value) + return value + + +def _deserialize_dataclass(cls: Type[T], data: dict) -> T: field_map = {f.name: f for f in fields(cls)} hints = get_type_hints(cls) norm = {k.replace("-", "_").lower(): v for k, v in data.items()} @@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T: if k not in field_map: continue f = field_map[k] - if isinstance(v, dict): - nested = _resolve_type(hints[f.name]) - if nested is not None: - v = deserialize(nested, v) - elif isinstance(v, list) and v: - item_tp = _resolve_list_item_type(hints[f.name]) - if item_tp is not None and _is_dataclass_type(item_tp): - v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v] - elif isinstance(v, str): - hint = hints.get(f.name) - raw_hint = hint - if get_origin(hint) is not None: - for a in get_args(hint): - if a is not type(None): - raw_hint = a - break - if raw_hint is datetime: - v = datetime.fromisoformat(v) - kwargs[k] = v + kwargs[k] = _deserialize_instance(hints[f.name], v) return cls(**kwargs) +_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { +} + + +def deserialize(cls: Type[T], data: dict) -> T: + """Create a typed instance from a dict, recursively deserializing nested values.""" + return _deserialize_instance(cls, data) + def _strip_nones(val: object) -> object: if isinstance(val, dict): diff --git a/packages/generator/tests/upload/snapshots/ts/client.ts b/packages/generator/tests/upload/snapshots/ts/client.ts index e0f1ee4f..62018e8b 100644 --- a/packages/generator/tests/upload/snapshots/ts/client.ts +++ b/packages/generator/tests/upload/snapshots/ts/client.ts @@ -1,5 +1,5 @@ import { FileUploadRequest, OAuthError, UploadParams } from "./types.js"; -import { deserialize, fetchWithRetry } from "./utils.js"; +import { deserialize, deserializeType, fetchWithRetry } from "./utils.js"; export class CommonService { async uploadFile(directUrl: string, request: FileUploadRequest): Promise { @@ -52,7 +52,7 @@ export class CommonServiceImpl extends CommonService { const body = await response.json(); switch (response.status) { case 201: - return deserialize(body.data) as UploadParams; + return deserializeType("UploadParams", body.data) as UploadParams; case 401: throw new OAuthError(body.error); default: diff --git a/packages/generator/tests/upload/snapshots/ts/utils.ts b/packages/generator/tests/upload/snapshots/ts/utils.ts index a471ae4d..35778d63 100644 --- a/packages/generator/tests/upload/snapshots/ts/utils.ts +++ b/packages/generator/tests/upload/snapshots/ts/utils.ts @@ -10,6 +10,32 @@ function camelToSnake(str: string): string { .toLowerCase(); } +function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj); +} + +function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj); +} + +function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)])); + } + return deserialize(obj); +} + +function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + return Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, mapValue(v)]), + ); + } + return serialize(obj); +} + export function deserialize(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(deserialize); if (obj !== null && typeof obj === "object") { @@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown { return obj; } +const TYPE_DESERIALIZERS: Record unknown> = { +}; + +export function deserializeType(type: string, obj: unknown): unknown { + return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj); +} + +export function serializeType(_type: string, obj: unknown): unknown { + return serialize(obj); +} + const MAX_RETRIES = 3; const RETRYABLE_5XX = new Set([500, 502, 503, 504]); diff --git a/packages/openapi-parser/src/schema-parser.ts b/packages/openapi-parser/src/schema-parser.ts index 6ca0025a..f955c75b 100644 --- a/packages/openapi-parser/src/schema-parser.ts +++ b/packages/openapi-parser/src/schema-parser.ts @@ -75,6 +75,7 @@ export function parseSchema( readOnly: getBoolean(schema, 'readOnly'), writeOnly: getBoolean(schema, 'writeOnly'), deprecated: getBoolean(schema, 'deprecated'), + 'x-union-deserializer': getString(schema, 'x-union-deserializer'), }; // Properties diff --git a/packages/openapi-parser/src/types.ts b/packages/openapi-parser/src/types.ts index 4785322d..5af3cc3b 100644 --- a/packages/openapi-parser/src/types.ts +++ b/packages/openapi-parser/src/types.ts @@ -119,6 +119,7 @@ export interface Schema { oneOf?: Schema[]; anyOf?: Schema[]; additionalProperties?: boolean | Schema; + 'x-union-deserializer'?: string; } export interface Example { diff --git a/packages/spec/openapi.en.yaml b/packages/spec/openapi.en.yaml index 62ad8475..045d1a3d 100644 --- a/packages/spec/openapi.en.yaml +++ b/packages/spec/openapi.en.yaml @@ -8737,6 +8737,7 @@ components: - $ref: '#/components/schemas/CompanyMemberWebhookPayload' - $ref: '#/components/schemas/LinkSharedWebhookPayload' description: Union of all webhook payload types + x-union-deserializer: webhook-payload securitySchemes: BearerAuth: type: http diff --git a/packages/spec/openapi.yaml b/packages/spec/openapi.yaml index f698fc68..65bd84b9 100644 --- a/packages/spec/openapi.yaml +++ b/packages/spec/openapi.yaml @@ -8559,6 +8559,7 @@ components: - $ref: '#/components/schemas/CompanyMemberWebhookPayload' - $ref: '#/components/schemas/LinkSharedWebhookPayload' description: Объединение всех типов payload вебхуков + x-union-deserializer: webhook-payload securitySchemes: BearerAuth: type: http diff --git a/packages/spec/typespec.tsp b/packages/spec/typespec.tsp index cd1ae9b7..690e9824 100644 --- a/packages/spec/typespec.tsp +++ b/packages/spec/typespec.tsp @@ -3036,6 +3036,7 @@ model LinkSharedWebhookPayload { } @doc("Объединение всех типов payload вебхуков") +@extension("x-union-deserializer", "webhook-payload") union WebhookPayloadUnion { MessageWebhookPayload, ReactionWebhookPayload, diff --git a/sdk/csharp/examples/Program.cs b/sdk/csharp/examples/Program.cs index 206361e0..3bb5c926 100644 --- a/sdk/csharp/examples/Program.cs +++ b/sdk/csharp/examples/Program.cs @@ -8,6 +8,7 @@ "upload" => await UploadExample.RunAsync(), "stub" => await StubExample.RunAsync(), "httpclient" => await HttpClientExample.RunAsync(), + "webhook-history" => await WebhookHistoryExample.RunAsync(), _ => PrintUsage() }; @@ -16,14 +17,15 @@ static int PrintUsage() Console.WriteLine("Usage: dotnet run -- "); Console.WriteLine(); Console.WriteLine("Examples:"); - Console.WriteLine(" main - Echo bot (create, read, react, thread, pin, update, unpin)"); - Console.WriteLine(" upload - File upload (requires PACHCA_FILE_PATH)"); - Console.WriteLine(" stub - Stub client with dependency injection"); - Console.WriteLine(" httpclient - Pre-configured HttpClient"); + Console.WriteLine(" main - Echo bot (create, read, react, thread, pin, update, unpin)"); + Console.WriteLine(" upload - File upload (requires PACHCA_FILE_PATH)"); + Console.WriteLine(" stub - Stub client with dependency injection"); + Console.WriteLine(" httpclient - Pre-configured HttpClient"); + Console.WriteLine(" webhook-history - Fetch recent webhook deliveries"); Console.WriteLine(); Console.WriteLine("Environment variables:"); - Console.WriteLine(" PACHCA_TOKEN - API token (required)"); - Console.WriteLine(" PACHCA_CHAT_ID - Chat ID (required)"); + Console.WriteLine(" PACHCA_TOKEN - API token (required)"); + Console.WriteLine(" PACHCA_CHAT_ID - Chat ID (required for main/httpclient)"); Console.WriteLine(" PACHCA_FILE_PATH - File path (upload only)"); return 1; } diff --git a/sdk/csharp/examples/WebhookHistoryExample.cs b/sdk/csharp/examples/WebhookHistoryExample.cs new file mode 100644 index 00000000..9c686ddf --- /dev/null +++ b/sdk/csharp/examples/WebhookHistoryExample.cs @@ -0,0 +1,43 @@ +/** + * Webhook history example — fetch recent webhook deliveries and inspect payload variants. + * + * Usage: + * + * PACHCA_TOKEN=your_token dotnet run -- webhook-history + */ + +namespace Pachca.Sdk.Examples; + +public static class WebhookHistoryExample +{ + public static async Task RunAsync() + { + var token = Environment.GetEnvironmentVariable("PACHCA_TOKEN") + ?? throw new InvalidOperationException("Set PACHCA_TOKEN environment variable"); + + var client = new PachcaClient(token); + var response = await client.Bots.GetWebhookEventsAsync(limit: 5); + + Console.WriteLine($"Fetched {response.Data.Count} webhook events"); + for (var index = 0; index < response.Data.Count; index++) + { + var @event = response.Data[index]; + Console.WriteLine($"{index + 1}. id={@event.Id} created_at={@event.CreatedAt:O} payload={SummarizePayload(@event.Payload)}"); + } + + Console.WriteLine($"has_next={response.Meta.Paginate.HasNext} next_page=\"{response.Meta.Paginate.NextPage}\""); + return 0; + } + + private static string SummarizePayload(WebhookPayloadUnion payload) => payload switch + { + LinkSharedWebhookPayload linkShared => $"link_shared message_id={linkShared.MessageId} links={linkShared.Links.Count} user_id={linkShared.UserId}", + MessageWebhookPayload message => $"message event={message.Event} id={message.Id} chat_id={message.ChatId}", + ReactionWebhookPayload reaction => $"reaction event={reaction.Event} message_id={reaction.MessageId} code={reaction.Code}", + ButtonWebhookPayload button => $"button message_id={button.MessageId} user_id={button.UserId}", + ViewSubmitWebhookPayload view => $"view user_id={view.UserId} fields={view.Data.Count}", + ChatMemberWebhookPayload member => $"chat_member event={member.Event} chat_id={member.ChatId} users={member.UserIds.Count}", + CompanyMemberWebhookPayload member => $"company_member event={member.Event} users={member.UserIds.Count}", + _ => $"unknown type={payload.GetType().Name}", + }; +} diff --git a/sdk/csharp/generated/Models.cs b/sdk/csharp/generated/Models.cs index 60f1a3c8..5b650b6c 100644 --- a/sdk/csharp/generated/Models.cs +++ b/sdk/csharp/generated/Models.cs @@ -1587,6 +1587,7 @@ public abstract class ViewBlockUnion public class ViewBlockHeader : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "header"; [JsonPropertyName("text")] public string Text { get; set; } = default!; @@ -1594,6 +1595,7 @@ public class ViewBlockHeader : ViewBlockUnion public class ViewBlockPlainText : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "plain_text"; [JsonPropertyName("text")] public string Text { get; set; } = default!; @@ -1601,6 +1603,7 @@ public class ViewBlockPlainText : ViewBlockUnion public class ViewBlockMarkdown : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "markdown"; [JsonPropertyName("text")] public string Text { get; set; } = default!; @@ -1608,11 +1611,13 @@ public class ViewBlockMarkdown : ViewBlockUnion public class ViewBlockDivider : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "divider"; } public class ViewBlockInput : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "input"; [JsonPropertyName("name")] public string Name { get; set; } = default!; @@ -1636,6 +1641,7 @@ public class ViewBlockInput : ViewBlockUnion public class ViewBlockSelect : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "select"; [JsonPropertyName("name")] public string Name { get; set; } = default!; @@ -1651,6 +1657,7 @@ public class ViewBlockSelect : ViewBlockUnion public class ViewBlockRadio : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "radio"; [JsonPropertyName("name")] public string Name { get; set; } = default!; @@ -1666,6 +1673,7 @@ public class ViewBlockRadio : ViewBlockUnion public class ViewBlockCheckbox : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "checkbox"; [JsonPropertyName("name")] public string Name { get; set; } = default!; @@ -1681,6 +1689,7 @@ public class ViewBlockCheckbox : ViewBlockUnion public class ViewBlockDate : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "date"; [JsonPropertyName("name")] public string Name { get; set; } = default!; @@ -1696,6 +1705,7 @@ public class ViewBlockDate : ViewBlockUnion public class ViewBlockTime : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "time"; [JsonPropertyName("name")] public string Name { get; set; } = default!; @@ -1711,6 +1721,7 @@ public class ViewBlockTime : ViewBlockUnion public class ViewBlockFileInput : ViewBlockUnion { + [JsonPropertyName("type")] public override string Type => "file_input"; [JsonPropertyName("name")] public string Name { get; set; } = default!; @@ -1726,22 +1737,44 @@ public class ViewBlockFileInput : ViewBlockUnion public string? Hint { get; set; } } -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(MessageWebhookPayload), "message")] -[JsonDerivedType(typeof(ReactionWebhookPayload), "reaction")] -[JsonDerivedType(typeof(ButtonWebhookPayload), "button")] -[JsonDerivedType(typeof(ViewSubmitWebhookPayload), "view")] -[JsonDerivedType(typeof(ChatMemberWebhookPayload), "chat_member")] -[JsonDerivedType(typeof(CompanyMemberWebhookPayload), "company_member")] -[JsonDerivedType(typeof(LinkSharedWebhookPayload), "message")] +[JsonConverter(typeof(WebhookPayloadUnionConverter))] public abstract class WebhookPayloadUnion { [JsonPropertyName("type")] public abstract string Type { get; } } +internal sealed class WebhookPayloadUnionConverter : JsonConverter +{ + public override WebhookPayloadUnion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + var type = root.GetProperty("type").GetString(); + var eventValue = root.TryGetProperty("event", out var eventProperty) ? eventProperty.GetString() : null; + var raw = root.GetRawText(); + return type switch + { + "message" when eventValue == "link_shared" => JsonSerializer.Deserialize(raw, options)!, + "message" => JsonSerializer.Deserialize(raw, options)!, + "reaction" => JsonSerializer.Deserialize(raw, options)!, + "button" => JsonSerializer.Deserialize(raw, options)!, + "view" => JsonSerializer.Deserialize(raw, options)!, + "chat_member" => JsonSerializer.Deserialize(raw, options)!, + "company_member" => JsonSerializer.Deserialize(raw, options)!, + _ => throw new JsonException($"Unknown WebhookPayloadUnion type: {type}") + }; + } + + public override void Write(Utf8JsonWriter writer, WebhookPayloadUnion value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, (object)value, value.GetType(), options); + } +} + public class MessageWebhookPayload : WebhookPayloadUnion { + [JsonPropertyName("type")] public override string Type => "message"; [JsonPropertyName("id")] public int Id { get; set; } = default!; @@ -1771,6 +1804,7 @@ public class MessageWebhookPayload : WebhookPayloadUnion public class ReactionWebhookPayload : WebhookPayloadUnion { + [JsonPropertyName("type")] public override string Type => "reaction"; [JsonPropertyName("event")] public ReactionEventType @Event { get; set; } = default!; @@ -1792,7 +1826,10 @@ public class ReactionWebhookPayload : WebhookPayloadUnion public class ButtonWebhookPayload : WebhookPayloadUnion { + [JsonPropertyName("type")] public override string Type => "button"; + [JsonPropertyName("event")] + public string @Event => "click"; [JsonPropertyName("message_id")] public int MessageId { get; set; } = default!; [JsonPropertyName("trigger_id")] @@ -1809,7 +1846,10 @@ public class ButtonWebhookPayload : WebhookPayloadUnion public class ViewSubmitWebhookPayload : WebhookPayloadUnion { + [JsonPropertyName("type")] public override string Type => "view"; + [JsonPropertyName("event")] + public string @Event => "submit"; [JsonPropertyName("callback_id")] public string? CallbackId { get; set; } [JsonPropertyName("private_metadata")] @@ -1826,6 +1866,7 @@ public class ViewSubmitWebhookPayload : WebhookPayloadUnion public class ChatMemberWebhookPayload : WebhookPayloadUnion { + [JsonPropertyName("type")] public override string Type => "chat_member"; [JsonPropertyName("event")] public MemberEventType @Event { get; set; } = default!; @@ -1843,6 +1884,7 @@ public class ChatMemberWebhookPayload : WebhookPayloadUnion public class CompanyMemberWebhookPayload : WebhookPayloadUnion { + [JsonPropertyName("type")] public override string Type => "company_member"; [JsonPropertyName("event")] public UserEventType @Event { get; set; } = default!; @@ -1856,7 +1898,10 @@ public class CompanyMemberWebhookPayload : WebhookPayloadUnion public class LinkSharedWebhookPayload : WebhookPayloadUnion { + [JsonPropertyName("type")] public override string Type => "message"; + [JsonPropertyName("event")] + public string @Event => "link_shared"; [JsonPropertyName("chat_id")] public int ChatId { get; set; } = default!; [JsonPropertyName("message_id")] diff --git a/sdk/go/examples/webhook_history.go b/sdk/go/examples/webhook_history.go new file mode 100644 index 00000000..2e0c4f56 --- /dev/null +++ b/sdk/go/examples/webhook_history.go @@ -0,0 +1,70 @@ +// Webhook history example — fetch recent webhook deliveries and inspect payload variants. +// +// Usage: +// +// PACHCA_TOKEN=your_token go run webhook_history.go +package main + +import ( + "context" + "fmt" + "log" + "os" + + pachca "github.com/pachca/openapi/sdk/go/generated" +) + +func main() { + token := os.Getenv("PACHCA_TOKEN") + if token == "" { + log.Fatal("Set PACHCA_TOKEN environment variable") + } + + client := pachca.NewPachcaClient(token) + limit := int32(5) + response, err := client.Bots.GetWebhookEvents(context.Background(), &pachca.GetWebhookEventsParams{ + Limit: &limit, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Fetched %d webhook events\n", len(response.Data)) + for i, event := range response.Data { + fmt.Printf("%d. id=%s created_at=%s payload=%s\n", i+1, event.ID, event.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), summarizePayload(event.Payload)) + } + + fmt.Printf("has_next=%v next_page=%q\n", boolValue(response.Meta.Paginate.HasNext), response.Meta.Paginate.NextPage) +} + +func boolValue(value *bool) bool { + return value != nil && *value +} + +func summarizePayload(payload pachca.WebhookPayloadUnion) string { + switch { + case payload.LinkSharedWebhookPayload != nil: + p := payload.LinkSharedWebhookPayload + return fmt.Sprintf("link_shared message_id=%d links=%d user_id=%d", p.MessageID, len(p.Links), p.UserID) + case payload.MessageWebhookPayload != nil: + p := payload.MessageWebhookPayload + return fmt.Sprintf("message event=%s id=%d chat_id=%d", p.Event, p.ID, p.ChatID) + case payload.ReactionWebhookPayload != nil: + p := payload.ReactionWebhookPayload + return fmt.Sprintf("reaction event=%s message_id=%d code=%s", p.Event, p.MessageID, p.Code) + case payload.ButtonWebhookPayload != nil: + p := payload.ButtonWebhookPayload + return fmt.Sprintf("button message_id=%d user_id=%d", p.MessageID, p.UserID) + case payload.ViewSubmitWebhookPayload != nil: + p := payload.ViewSubmitWebhookPayload + return fmt.Sprintf("view user_id=%d fields=%d", p.UserID, len(p.Data)) + case payload.ChatMemberWebhookPayload != nil: + p := payload.ChatMemberWebhookPayload + return fmt.Sprintf("chat_member event=%s chat_id=%d users=%d", p.Event, p.ChatID, len(p.UserIDs)) + case payload.CompanyMemberWebhookPayload != nil: + p := payload.CompanyMemberWebhookPayload + return fmt.Sprintf("company_member event=%s users=%d", p.Event, len(p.UserIDs)) + default: + return "unknown" + } +} diff --git a/sdk/go/generated/types.go b/sdk/go/generated/types.go index d1c23e3c..4f2d8cc0 100644 --- a/sdk/go/generated/types.go +++ b/sdk/go/generated/types.go @@ -1564,27 +1564,31 @@ type WebhookPayloadUnion struct { func (u *WebhookPayloadUnion) UnmarshalJSON(data []byte) error { var disc struct { Type string `json:"type"` + Event string `json:"event"` } if err := json.Unmarshal(data, &disc); err != nil { return err } - switch disc.Type { - case "message": + switch { + case disc.Type == "message" && disc.Event == "link_shared": + u.LinkSharedWebhookPayload = &LinkSharedWebhookPayload{} + return json.Unmarshal(data, u.LinkSharedWebhookPayload) + case disc.Type == "message": u.MessageWebhookPayload = &MessageWebhookPayload{} return json.Unmarshal(data, u.MessageWebhookPayload) - case "reaction": + case disc.Type == "reaction": u.ReactionWebhookPayload = &ReactionWebhookPayload{} return json.Unmarshal(data, u.ReactionWebhookPayload) - case "button": + case disc.Type == "button": u.ButtonWebhookPayload = &ButtonWebhookPayload{} return json.Unmarshal(data, u.ButtonWebhookPayload) - case "view": + case disc.Type == "view": u.ViewSubmitWebhookPayload = &ViewSubmitWebhookPayload{} return json.Unmarshal(data, u.ViewSubmitWebhookPayload) - case "chat_member": + case disc.Type == "chat_member": u.ChatMemberWebhookPayload = &ChatMemberWebhookPayload{} return json.Unmarshal(data, u.ChatMemberWebhookPayload) - case "company_member": + case disc.Type == "company_member": u.CompanyMemberWebhookPayload = &CompanyMemberWebhookPayload{} return json.Unmarshal(data, u.CompanyMemberWebhookPayload) default: diff --git a/sdk/kotlin/examples/webhook_history.kt b/sdk/kotlin/examples/webhook_history.kt new file mode 100644 index 00000000..b831f400 --- /dev/null +++ b/sdk/kotlin/examples/webhook_history.kt @@ -0,0 +1,37 @@ +/** + * Webhook history example — fetch recent webhook deliveries and inspect payload variants. + * + * Usage: + * + * PACHCA_TOKEN=your_token kotlin webhook_history.kt + */ +package examples.webhookhistory + +import com.pachca.sdk.* +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + val token = System.getenv("PACHCA_TOKEN") + ?: error("Set PACHCA_TOKEN environment variable") + + val client = PachcaClient(token) + val response = client.bots.getWebhookEvents(limit = 5) + + println("Fetched ${response.data.size} webhook events") + response.data.forEachIndexed { index, event -> + println("${index + 1}. id=${event.id} created_at=${event.createdAt} payload=${summarizePayload(event.payload)}") + } + + println("has_next=${response.meta.paginate.hasNext} next_page=${response.meta.paginate.nextPage}") + client.close() +} + +private fun summarizePayload(payload: WebhookPayloadUnion): String = when (payload) { + is LinkSharedWebhookPayload -> "link_shared message_id=${payload.messageId} links=${payload.links.size} user_id=${payload.userId}" + is MessageWebhookPayload -> "message event=${payload.event} id=${payload.id} chat_id=${payload.chatId}" + is ReactionWebhookPayload -> "reaction event=${payload.event} message_id=${payload.messageId} code=${payload.code}" + is ButtonWebhookPayload -> "button message_id=${payload.messageId} user_id=${payload.userId}" + is ViewSubmitWebhookPayload -> "view user_id=${payload.userId} fields=${payload.data.size}" + is ChatMemberWebhookPayload -> "chat_member event=${payload.event} chat_id=${payload.chatId} users=${payload.userIds.size}" + is CompanyMemberWebhookPayload -> "company_member event=${payload.event} users=${payload.userIds.size}" +} diff --git a/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt index ed82e849..d57a67a6 100644 --- a/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt +++ b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt @@ -8,8 +8,15 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive object OffsetDateTimeSerializer : KSerializer { override val descriptor = PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.STRING) @@ -741,11 +748,45 @@ data class ViewBlockFileInput( val hint: String? = null, ) : ViewBlockUnion -@Serializable +@Serializable(with = WebhookPayloadUnionSerializer::class) sealed interface WebhookPayloadUnion { val type: String } +object WebhookPayloadUnionSerializer : KSerializer { + override val descriptor = buildClassSerialDescriptor("WebhookPayloadUnion") + + override fun serialize(encoder: Encoder, value: WebhookPayloadUnion) { + val jsonEncoder = encoder as? JsonEncoder ?: error("WebhookPayloadUnionSerializer only supports JSON") + when (value) { + is MessageWebhookPayload -> jsonEncoder.encodeSerializableValue(MessageWebhookPayload.serializer(), value) + is ReactionWebhookPayload -> jsonEncoder.encodeSerializableValue(ReactionWebhookPayload.serializer(), value) + is ButtonWebhookPayload -> jsonEncoder.encodeSerializableValue(ButtonWebhookPayload.serializer(), value) + is ViewSubmitWebhookPayload -> jsonEncoder.encodeSerializableValue(ViewSubmitWebhookPayload.serializer(), value) + is ChatMemberWebhookPayload -> jsonEncoder.encodeSerializableValue(ChatMemberWebhookPayload.serializer(), value) + is CompanyMemberWebhookPayload -> jsonEncoder.encodeSerializableValue(CompanyMemberWebhookPayload.serializer(), value) + is LinkSharedWebhookPayload -> jsonEncoder.encodeSerializableValue(LinkSharedWebhookPayload.serializer(), value) + } + } + + override fun deserialize(decoder: Decoder): WebhookPayloadUnion { + val jsonDecoder = decoder as? JsonDecoder ?: error("WebhookPayloadUnionSerializer only supports JSON") + val element = jsonDecoder.decodeJsonElement() + val type = element.jsonObject["type"]?.jsonPrimitive?.contentOrNull + val event = element.jsonObject["event"]?.jsonPrimitive?.contentOrNull + return when { + type == "message" && event == "link_shared" -> jsonDecoder.json.decodeFromJsonElement(LinkSharedWebhookPayload.serializer(), element) + type == "message" -> jsonDecoder.json.decodeFromJsonElement(MessageWebhookPayload.serializer(), element) + type == "reaction" -> jsonDecoder.json.decodeFromJsonElement(ReactionWebhookPayload.serializer(), element) + type == "button" -> jsonDecoder.json.decodeFromJsonElement(ButtonWebhookPayload.serializer(), element) + type == "view" -> jsonDecoder.json.decodeFromJsonElement(ViewSubmitWebhookPayload.serializer(), element) + type == "chat_member" -> jsonDecoder.json.decodeFromJsonElement(ChatMemberWebhookPayload.serializer(), element) + type == "company_member" -> jsonDecoder.json.decodeFromJsonElement(CompanyMemberWebhookPayload.serializer(), element) + else -> error("Unknown WebhookPayloadUnion type: $type") + } + } +} + @Serializable @SerialName("message") data class MessageWebhookPayload( @@ -788,7 +829,9 @@ data class ButtonWebhookPayload( @SerialName("user_id") val userId: Int, @SerialName("chat_id") val chatId: Int, @SerialName("webhook_timestamp") val webhookTimestamp: Int, -) : WebhookPayloadUnion +) : WebhookPayloadUnion { + val event: String = "click" +} @Serializable @SerialName("view") @@ -800,7 +843,9 @@ data class ViewSubmitWebhookPayload( @SerialName("user_id") val userId: Int, val data: Map, @SerialName("webhook_timestamp") val webhookTimestamp: Int, -) : WebhookPayloadUnion +) : WebhookPayloadUnion { + val event: String = "submit" +} @Serializable @SerialName("chat_member") @@ -834,7 +879,9 @@ data class LinkSharedWebhookPayload( @SerialName("user_id") val userId: Int, @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, @SerialName("webhook_timestamp") val webhookTimestamp: Int, -) : WebhookPayloadUnion +) : WebhookPayloadUnion { + val event: String = "link_shared" +} @Serializable data class AccessTokenInfo( diff --git a/sdk/python/examples/webhook_history.py b/sdk/python/examples/webhook_history.py new file mode 100644 index 00000000..a61555f1 --- /dev/null +++ b/sdk/python/examples/webhook_history.py @@ -0,0 +1,67 @@ +""" +Webhook history example — fetch recent webhook deliveries and inspect payload variants. + +Usage: + PACHCA_TOKEN=... python examples/webhook_history.py +""" + +import asyncio +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "generated")) + +from pachca.client import PachcaClient +from pachca.models import ( + ButtonWebhookPayload, + ChatMemberWebhookPayload, + CompanyMemberWebhookPayload, + GetWebhookEventsParams, + LinkSharedWebhookPayload, + MessageWebhookPayload, + ReactionWebhookPayload, + ViewSubmitWebhookPayload, + WebhookPayloadUnion, +) + + +def summarize_payload(payload: WebhookPayloadUnion) -> str: + match payload: + case LinkSharedWebhookPayload(): + return f"link_shared message_id={payload.message_id} links={len(payload.links)} user_id={payload.user_id}" + case MessageWebhookPayload(): + return f"message event={payload.event} id={payload.id} chat_id={payload.chat_id}" + case ReactionWebhookPayload(): + return f"reaction event={payload.event} message_id={payload.message_id} code={payload.code}" + case ButtonWebhookPayload(): + return f"button message_id={payload.message_id} user_id={payload.user_id}" + case ViewSubmitWebhookPayload(): + return f"view user_id={payload.user_id} fields={len(payload.data)}" + case ChatMemberWebhookPayload(): + return f"chat_member event={payload.event} chat_id={payload.chat_id} users={len(payload.user_ids)}" + case CompanyMemberWebhookPayload(): + return f"company_member event={payload.event} users={len(payload.user_ids)}" + case _: + return f"unknown type={type(payload).__name__}" + + +async def main(): + token = os.environ["PACHCA_TOKEN"] + + client = PachcaClient(token) + response = await client.bots.get_webhook_events(GetWebhookEventsParams(limit=5)) + + print(f"Fetched {len(response.data)} webhook events") + for index, event in enumerate(response.data, start=1): + print( + f"{index}. id={event.id} created_at={event.created_at.isoformat()} payload={summarize_payload(event.payload)}" + ) + + print( + f'has_next={response.meta.paginate.has_next} next_page="{response.meta.paginate.next_page}"' + ) + + await client.close() + + +asyncio.run(main()) diff --git a/sdk/python/generated/pachca/utils.py b/sdk/python/generated/pachca/utils.py index 28f6ac8b..14f1ada6 100644 --- a/sdk/python/generated/pachca/utils.py +++ b/sdk/python/generated/pachca/utils.py @@ -4,18 +4,29 @@ import keyword from dataclasses import asdict, fields from datetime import datetime -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints import httpx +from .models import ( + ButtonWebhookPayload, + ChatMemberWebhookPayload, + CompanyMemberWebhookPayload, + LinkSharedWebhookPayload, + MessageWebhookPayload, + ReactionWebhookPayload, + ViewSubmitWebhookPayload, + WebhookPayloadUnion, +) + T = TypeVar("T") -def _is_dataclass_type(tp: type) -> bool: +def _is_dataclass_type(tp: object) -> bool: return isinstance(tp, type) and dataclasses.is_dataclass(tp) -def _resolve_type(tp: type) -> type | None: +def _resolve_type(tp: object) -> type | None: """Extract a concrete dataclass type from Optional[X] or X | None.""" origin = get_origin(tp) if origin is list: @@ -29,7 +40,7 @@ def _resolve_type(tp: type) -> type | None: return None -def _resolve_list_item_type(tp: type) -> type | None: +def _resolve_list_item_type(tp: object) -> object | None: """Extract the item type from list[X].""" origin = get_origin(tp) if origin is list: @@ -39,8 +50,34 @@ def _resolve_list_item_type(tp: type) -> type | None: return None -def deserialize(cls: Type[T], data: dict) -> T: - """Create a dataclass instance from a dict, recursively deserializing nested dataclasses.""" +CustomUnionDeserializer = Callable[[dict], object] + + +def _deserialize_instance(tp: object, value: object) -> object: + custom = _CUSTOM_UNION_DESERIALIZERS.get(tp) + if custom is not None and isinstance(value, dict): + return custom(value) + if isinstance(value, dict): + nested = _resolve_type(tp) + if nested is not None: + return _deserialize_dataclass(nested, value) + if isinstance(value, list): + item_tp = _resolve_list_item_type(tp) + if item_tp is not None: + return [_deserialize_instance(item_tp, item) for item in value] + if isinstance(value, str): + raw_tp = tp + if get_origin(tp) is not None: + for arg in get_args(tp): + if arg is not type(None): + raw_tp = arg + break + if raw_tp is datetime: + return datetime.fromisoformat(value) + return value + + +def _deserialize_dataclass(cls: Type[T], data: dict) -> T: field_map = {f.name: f for f in fields(cls)} hints = get_type_hints(cls) norm = {k.replace("-", "_").lower(): v for k, v in data.items()} @@ -51,27 +88,37 @@ def deserialize(cls: Type[T], data: dict) -> T: if k not in field_map: continue f = field_map[k] - if isinstance(v, dict): - nested = _resolve_type(hints[f.name]) - if nested is not None: - v = deserialize(nested, v) - elif isinstance(v, list) and v: - item_tp = _resolve_list_item_type(hints[f.name]) - if item_tp is not None and _is_dataclass_type(item_tp): - v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v] - elif isinstance(v, str): - hint = hints.get(f.name) - raw_hint = hint - if get_origin(hint) is not None: - for a in get_args(hint): - if a is not type(None): - raw_hint = a - break - if raw_hint is datetime: - v = datetime.fromisoformat(v) - kwargs[k] = v + kwargs[k] = _deserialize_instance(hints[f.name], v) return cls(**kwargs) +def _webhook_payload_union_deserialize(data: dict) -> WebhookPayloadUnion: + match (data.get("type"), data.get("event")): + case ("message", "link_shared"): + return _deserialize_instance(LinkSharedWebhookPayload, data) + case ("message", _): + return _deserialize_instance(MessageWebhookPayload, data) + case ("reaction", _): + return _deserialize_instance(ReactionWebhookPayload, data) + case ("button", _): + return _deserialize_instance(ButtonWebhookPayload, data) + case ("view", _): + return _deserialize_instance(ViewSubmitWebhookPayload, data) + case ("chat_member", _): + return _deserialize_instance(ChatMemberWebhookPayload, data) + case ("company_member", _): + return _deserialize_instance(CompanyMemberWebhookPayload, data) + case _: + raise ValueError(f"Unknown WebhookPayloadUnion discriminator: {data.get('type')}") + +_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { + WebhookPayloadUnion: _webhook_payload_union_deserialize, +} + + +def deserialize(cls: Type[T], data: dict) -> T: + """Create a typed instance from a dict, recursively deserializing nested values.""" + return _deserialize_instance(cls, data) + def _strip_nones(val: object) -> object: if isinstance(val, dict): diff --git a/sdk/swift/examples/Package.swift b/sdk/swift/examples/Package.swift index 7248bff7..1c02bc02 100644 --- a/sdk/swift/examples/Package.swift +++ b/sdk/swift/examples/Package.swift @@ -36,5 +36,12 @@ let package = Package( ], path: "Sources/HttpClient" ), + .executableTarget( + name: "WebhookHistory", + dependencies: [ + .product(name: "PachcaSDK", package: "swift"), + ], + path: "Sources/WebhookHistory" + ), ] ) diff --git a/sdk/swift/examples/Sources/WebhookHistory/main.swift b/sdk/swift/examples/Sources/WebhookHistory/main.swift new file mode 100644 index 00000000..077e8aac --- /dev/null +++ b/sdk/swift/examples/Sources/WebhookHistory/main.swift @@ -0,0 +1,40 @@ +import Foundation +import PachcaSDK + +// Webhook history example — fetch recent webhook deliveries and inspect payload variants. +// +// Usage: +// PACHCA_TOKEN=your_token swift run WebhookHistory + +guard let token = ProcessInfo.processInfo.environment["PACHCA_TOKEN"] else { + fatalError("Set PACHCA_TOKEN environment variable") +} + +let client = PachcaClient(token: token) +let response = try await client.bots.getWebhookEvents(limit: 5) + +print("Fetched \(response.data.count) webhook events") +for (index, event) in response.data.enumerated() { + print("\(index + 1). id=\(event.id) created_at=\(event.createdAt) payload=\(summarizePayload(event.payload))") +} + +print("has_next=\(response.meta.paginate.hasNext ?? false) next_page=\(response.meta.paginate.nextPage)") + +func summarizePayload(_ payload: WebhookPayloadUnion) -> String { + switch payload { + case .linkSharedWebhookPayload(let linkShared): + return "link_shared message_id=\(linkShared.messageId) links=\(linkShared.links.count) user_id=\(linkShared.userId)" + case .messageWebhookPayload(let message): + return "message event=\(message.event) id=\(message.id) chat_id=\(message.chatId)" + case .reactionWebhookPayload(let reaction): + return "reaction event=\(reaction.event) message_id=\(reaction.messageId) code=\(reaction.code)" + case .buttonWebhookPayload(let button): + return "button message_id=\(button.messageId) user_id=\(button.userId)" + case .viewSubmitWebhookPayload(let view): + return "view user_id=\(view.userId) fields=\(view.data.count)" + case .chatMemberWebhookPayload(let member): + return "chat_member event=\(member.event) chat_id=\(String(describing: member.chatId)) users=\(member.userIds.count)" + case .companyMemberWebhookPayload(let member): + return "company_member event=\(member.event) users=\(member.userIds.count)" + } +} diff --git a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Models.swift b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Models.swift index 8b90f4c4..c42d809c 100644 --- a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Models.swift +++ b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Models.swift @@ -2783,23 +2783,27 @@ public enum WebhookPayloadUnion: Codable { private enum CodingKeys: String, CodingKey { case type + case event } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(String.self, forKey: .type) - switch type { - case "message": + let event = try? container.decode(String.self, forKey: .event) + switch (type, event) { + case ("message", "link_shared"): + self = .linkSharedWebhookPayload(try LinkSharedWebhookPayload(from: decoder)) + case ("message", _): self = .messageWebhookPayload(try MessageWebhookPayload(from: decoder)) - case "reaction": + case ("reaction", _): self = .reactionWebhookPayload(try ReactionWebhookPayload(from: decoder)) - case "button": + case ("button", _): self = .buttonWebhookPayload(try ButtonWebhookPayload(from: decoder)) - case "view": + case ("view", _): self = .viewSubmitWebhookPayload(try ViewSubmitWebhookPayload(from: decoder)) - case "chat_member": + case ("chat_member", _): self = .chatMemberWebhookPayload(try ChatMemberWebhookPayload(from: decoder)) - case "company_member": + case ("company_member", _): self = .companyMemberWebhookPayload(try CompanyMemberWebhookPayload(from: decoder)) default: throw DecodingError.dataCorrupted( diff --git a/sdk/typescript/examples/webhook-history.ts b/sdk/typescript/examples/webhook-history.ts new file mode 100644 index 00000000..5953f980 --- /dev/null +++ b/sdk/typescript/examples/webhook-history.ts @@ -0,0 +1,73 @@ +/** + * Webhook history example — fetch recent webhook deliveries and inspect payload variants. + * + * Usage: + * PACHCA_TOKEN=your_token bun run examples/webhook-history.ts + */ + +import { PachcaClient } from "../src/index.js"; +import type { + ButtonWebhookPayload, + ChatMemberWebhookPayload, + CompanyMemberWebhookPayload, + LinkSharedWebhookPayload, + MessageWebhookPayload, + ReactionWebhookPayload, + ViewSubmitWebhookPayload, + WebhookPayloadUnion, +} from "../src/generated/types.js"; + +const token = process.env.PACHCA_TOKEN; +if (!token) { + console.error("Set PACHCA_TOKEN environment variable"); + process.exit(1); +} + +const client = new PachcaClient(token); +const response = await client.bots.getWebhookEvents({ limit: 5 }); + +console.log(`Fetched ${response.data.length} webhook events`); +for (const [index, event] of response.data.entries()) { + console.log( + `${index + 1}. id=${event.id} created_at=${event.createdAt} payload=${summarizePayload(event.payload)}`, + ); +} + +console.log( + `has_next=${response.meta.paginate.hasNext} next_page=${JSON.stringify(response.meta.paginate.nextPage)}`, +); + +function summarizePayload(payload: WebhookPayloadUnion): string { + if (payload.type === "message" && payload.event === "link_shared") { + const linkShared = payload as LinkSharedWebhookPayload; + return `link_shared message_id=${linkShared.messageId} links=${linkShared.links.length} user_id=${linkShared.userId}`; + } + switch (payload.type) { + case "message": { + const message = payload as MessageWebhookPayload; + return `message event=${message.event} id=${message.id} chat_id=${message.chatId}`; + } + case "reaction": { + const reaction = payload as ReactionWebhookPayload; + return `reaction event=${reaction.event} message_id=${reaction.messageId} code=${reaction.code}`; + } + case "button": { + const button = payload as ButtonWebhookPayload; + return `button message_id=${button.messageId} user_id=${button.userId}`; + } + case "view": { + const view = payload as ViewSubmitWebhookPayload; + return `view user_id=${view.userId} fields=${Object.keys(view.data).length}`; + } + case "chat_member": { + const member = payload as ChatMemberWebhookPayload; + return `chat_member event=${member.event} chat_id=${member.chatId} users=${member.userIds.length}`; + } + case "company_member": { + const member = payload as CompanyMemberWebhookPayload; + return `company_member event=${member.event} users=${member.userIds.length}`; + } + default: + return "unknown"; + } +} diff --git a/sdk/typescript/src/generated/client.ts b/sdk/typescript/src/generated/client.ts index 23348494..b3e8367f 100644 --- a/sdk/typescript/src/generated/client.ts +++ b/sdk/typescript/src/generated/client.ts @@ -66,7 +66,7 @@ import { UserUpdateRequest, OpenViewRequest, } from "./types.js"; -import { deserialize, serialize, fetchWithRetry } from "./utils.js"; +import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js"; export class SecurityService { async getAuditEvents(params?: GetAuditEventsParams): Promise { @@ -164,7 +164,7 @@ export class BotsServiceImpl extends BotsService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body) as GetWebhookEventsResponse; + return { ...(deserialize(body) as GetWebhookEventsResponse), data: Array.isArray(body.data) ? body.data.map((item: unknown) => deserializeType("WebhookEvent", item) as WebhookEvent) : [] } as GetWebhookEventsResponse; case 401: throw new OAuthError(body.error); default: @@ -190,12 +190,12 @@ export class BotsServiceImpl extends BotsService { const response = await fetchWithRetry(`${this.baseUrl}/bots/${id}`, { method: "PUT", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("BotUpdateRequest", request)), }); const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as BotResponse; + return deserializeType("BotResponse", body.data) as BotResponse; case 401: throw new OAuthError(body.error); default: @@ -303,7 +303,7 @@ export class ChatsServiceImpl extends ChatsService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Chat; + return deserializeType("Chat", body.data) as Chat; case 401: throw new OAuthError(body.error); default: @@ -315,12 +315,12 @@ export class ChatsServiceImpl extends ChatsService { const response = await fetchWithRetry(`${this.baseUrl}/chats`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("ChatCreateRequest", request)), }); const body = await response.json(); switch (response.status) { case 201: - return deserialize(body.data) as Chat; + return deserializeType("Chat", body.data) as Chat; case 401: throw new OAuthError(body.error); default: @@ -332,12 +332,12 @@ export class ChatsServiceImpl extends ChatsService { const response = await fetchWithRetry(`${this.baseUrl}/chats/${id}`, { method: "PUT", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("ChatUpdateRequest", request)), }); const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Chat; + return deserializeType("Chat", body.data) as Chat; case 401: throw new OAuthError(body.error); default: @@ -447,7 +447,7 @@ export class CommonServiceImpl extends CommonService { const response = await fetchWithRetry(`${this.baseUrl}/chats/exports`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("ExportRequest", request)), }); switch (response.status) { case 204: @@ -490,7 +490,7 @@ export class CommonServiceImpl extends CommonService { const body = await response.json(); switch (response.status) { case 201: - return deserialize(body) as UploadParams; + return deserializeType("UploadParams", body) as UploadParams; case 401: throw new OAuthError(body.error); default: @@ -595,7 +595,7 @@ export class MembersServiceImpl extends MembersService { const response = await fetchWithRetry(`${this.baseUrl}/chats/${id}/members`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("AddMembersRequest", request)), }); switch (response.status) { case 204: @@ -754,7 +754,7 @@ export class GroupTagsServiceImpl extends GroupTagsService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as GroupTag; + return deserializeType("GroupTag", body.data) as GroupTag; case 401: throw new OAuthError(body.error); default: @@ -799,12 +799,12 @@ export class GroupTagsServiceImpl extends GroupTagsService { const response = await fetchWithRetry(`${this.baseUrl}/group_tags`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("GroupTagRequest", request)), }); const body = await response.json(); switch (response.status) { case 201: - return deserialize(body.data) as GroupTag; + return deserializeType("GroupTag", body.data) as GroupTag; case 401: throw new OAuthError(body.error); default: @@ -816,12 +816,12 @@ export class GroupTagsServiceImpl extends GroupTagsService { const response = await fetchWithRetry(`${this.baseUrl}/group_tags/${id}`, { method: "PUT", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("GroupTagRequest", request)), }); const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as GroupTag; + return deserializeType("GroupTag", body.data) as GroupTag; case 401: throw new OAuthError(body.error); default: @@ -929,7 +929,7 @@ export class MessagesServiceImpl extends MessagesService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Message; + return deserializeType("Message", body.data) as Message; case 401: throw new OAuthError(body.error); default: @@ -941,12 +941,12 @@ export class MessagesServiceImpl extends MessagesService { const response = await fetchWithRetry(`${this.baseUrl}/messages`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("MessageCreateRequest", request)), }); const body = await response.json(); switch (response.status) { case 201: - return deserialize(body.data) as Message; + return deserializeType("Message", body.data) as Message; case 401: throw new OAuthError(body.error); default: @@ -973,12 +973,12 @@ export class MessagesServiceImpl extends MessagesService { const response = await fetchWithRetry(`${this.baseUrl}/messages/${id}`, { method: "PUT", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("MessageUpdateRequest", request)), }); const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Message; + return deserializeType("Message", body.data) as Message; case 401: throw new OAuthError(body.error); default: @@ -1035,7 +1035,7 @@ export class LinkPreviewsServiceImpl extends LinkPreviewsService { const response = await fetchWithRetry(`${this.baseUrl}/messages/${id}/link_previews`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("LinkPreviewsRequest", request)), }); switch (response.status) { case 204: @@ -1111,12 +1111,12 @@ export class ReactionsServiceImpl extends ReactionsService { const response = await fetchWithRetry(`${this.baseUrl}/messages/${id}/reactions`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("ReactionRequest", request)), }); const body = await response.json(); switch (response.status) { case 201: - return deserialize(body) as Reaction; + return deserializeType("Reaction", body) as Reaction; case 401: throw new OAuthError(body.error); default: @@ -1245,7 +1245,7 @@ export class ThreadsServiceImpl extends ThreadsService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Thread; + return deserializeType("Thread", body.data) as Thread; case 401: throw new OAuthError(body.error); default: @@ -1261,7 +1261,7 @@ export class ThreadsServiceImpl extends ThreadsService { const body = await response.json(); switch (response.status) { case 201: - return deserialize(body.data) as Thread; + return deserializeType("Thread", body.data) as Thread; case 401: throw new OAuthError(body.error); default: @@ -1315,7 +1315,7 @@ export class ProfileServiceImpl extends ProfileService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as AccessTokenInfo; + return deserializeType("AccessTokenInfo", body.data) as AccessTokenInfo; case 401: throw new OAuthError(body.error); default: @@ -1330,7 +1330,7 @@ export class ProfileServiceImpl extends ProfileService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as User; + return deserializeType("User", body.data) as User; case 401: throw new OAuthError(body.error); default: @@ -1345,7 +1345,7 @@ export class ProfileServiceImpl extends ProfileService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body) as unknown; + return deserializeType("unknown", body) as unknown; case 401: throw new OAuthError(body.error); default: @@ -1364,7 +1364,7 @@ export class ProfileServiceImpl extends ProfileService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as AvatarData; + return deserializeType("AvatarData", body.data) as AvatarData; case 401: throw new OAuthError(body.error); default: @@ -1376,12 +1376,12 @@ export class ProfileServiceImpl extends ProfileService { const response = await fetchWithRetry(`${this.baseUrl}/profile/status`, { method: "PUT", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("StatusUpdateRequest", request)), }); const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as UserStatus; + return deserializeType("UserStatus", body.data) as UserStatus; case 401: throw new OAuthError(body.error); default: @@ -1648,7 +1648,7 @@ export class TasksServiceImpl extends TasksService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Task; + return deserializeType("Task", body.data) as Task; case 401: throw new OAuthError(body.error); default: @@ -1660,12 +1660,12 @@ export class TasksServiceImpl extends TasksService { const response = await fetchWithRetry(`${this.baseUrl}/tasks`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("TaskCreateRequest", request)), }); const body = await response.json(); switch (response.status) { case 201: - return deserialize(body.data) as Task; + return deserializeType("Task", body.data) as Task; case 401: throw new OAuthError(body.error); default: @@ -1677,12 +1677,12 @@ export class TasksServiceImpl extends TasksService { const response = await fetchWithRetry(`${this.baseUrl}/tasks/${id}`, { method: "PUT", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("TaskUpdateRequest", request)), }); const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as Task; + return deserializeType("Task", body.data) as Task; case 401: throw new OAuthError(body.error); default: @@ -1801,7 +1801,7 @@ export class UsersServiceImpl extends UsersService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as User; + return deserializeType("User", body.data) as User; case 401: throw new OAuthError(body.error); default: @@ -1816,7 +1816,7 @@ export class UsersServiceImpl extends UsersService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body) as unknown; + return deserializeType("unknown", body) as unknown; case 401: throw new OAuthError(body.error); default: @@ -1828,12 +1828,12 @@ export class UsersServiceImpl extends UsersService { const response = await fetchWithRetry(`${this.baseUrl}/users`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("UserCreateRequest", request)), }); const body = await response.json(); switch (response.status) { case 201: - return deserialize(body.data) as User; + return deserializeType("User", body.data) as User; case 401: throw new OAuthError(body.error); default: @@ -1845,12 +1845,12 @@ export class UsersServiceImpl extends UsersService { const response = await fetchWithRetry(`${this.baseUrl}/users/${id}`, { method: "PUT", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("UserUpdateRequest", request)), }); const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as User; + return deserializeType("User", body.data) as User; case 401: throw new OAuthError(body.error); default: @@ -1869,7 +1869,7 @@ export class UsersServiceImpl extends UsersService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as AvatarData; + return deserializeType("AvatarData", body.data) as AvatarData; case 401: throw new OAuthError(body.error); default: @@ -1881,12 +1881,12 @@ export class UsersServiceImpl extends UsersService { const response = await fetchWithRetry(`${this.baseUrl}/users/${userId}/status`, { method: "PUT", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("StatusUpdateRequest", request)), }); const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as UserStatus; + return deserializeType("UserStatus", body.data) as UserStatus; case 401: throw new OAuthError(body.error); default: @@ -1958,7 +1958,7 @@ export class ViewsServiceImpl extends ViewsService { const response = await fetchWithRetry(`${this.baseUrl}/views/open`, { method: "POST", headers: { ...this.headers, "Content-Type": "application/json" }, - body: JSON.stringify(serialize(request)), + body: JSON.stringify(serializeType("OpenViewRequest", request)), }); switch (response.status) { case 201: diff --git a/sdk/typescript/src/generated/utils.ts b/sdk/typescript/src/generated/utils.ts index 6da10996..7aa40cec 100644 --- a/sdk/typescript/src/generated/utils.ts +++ b/sdk/typescript/src/generated/utils.ts @@ -12,21 +12,27 @@ function camelToSnake(str: string): string { const RECORD_KEYS = new Set(["payload", "filters", "link_previews", "linkPreviews", "data"]); -function deserializeRecord(obj: unknown): unknown { +function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj); +} + +function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown { + return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj); +} + +function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { - return Object.fromEntries( - Object.entries(obj).map(([k, v]) => [k, deserialize(v)]), - ); + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)])); } return deserialize(obj); } -function serializeRecord(obj: unknown): unknown { +function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown { if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { return Object.fromEntries( Object.entries(obj) .filter(([, v]) => v !== undefined) - .map(([k, v]) => [k, serialize(v)]), + .map(([k, v]) => [k, mapValue(v)]), ); } return serialize(obj); @@ -38,7 +44,7 @@ export function deserialize(obj: unknown): unknown { return Object.fromEntries( Object.entries(obj).map(([k, v]) => { const ck = snakeToCamel(k); - return [ck, RECORD_KEYS.has(ck) ? deserializeRecord(v) : deserialize(v)]; + return [ck, RECORD_KEYS.has(ck) ? deserializeRecordWith(v, deserialize) : deserialize(v)]; }), ); } @@ -51,14 +57,46 @@ export function serialize(obj: unknown): unknown { return Object.fromEntries( Object.entries(obj) .filter(([, v]) => v !== undefined) - .map(([k, v]) => { - return [camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecord(v) : serialize(v)]; - }), + .map(([k, v]) => [camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecordWith(v, serialize) : serialize(v)]), ); } return obj; } +function deserializeWebhookEvent(obj: unknown): unknown { + if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return deserialize(obj); + return Object.fromEntries( + Object.entries(obj) + .map(([k, v]) => { + const ck = snakeToCamel(k); + switch (ck) { + case "id": + return [ck, deserialize(v)]; + case "eventType": + return [ck, deserialize(v)]; + case "payload": + return [ck, deserialize(v)]; + case "createdAt": + return [ck, deserialize(v)]; + default: + return [ck, RECORD_KEYS.has(ck) ? deserializeRecordWith(v, deserialize) : deserialize(v)]; + } + }), + ); +} + +const TYPE_DESERIALIZERS: Record unknown> = { + "WebhookEvent": deserializeWebhookEvent, +}; + +export function deserializeType(type: string, obj: unknown): unknown { + return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj); +} + +export function serializeType(_type: string, obj: unknown): unknown { + return serialize(obj); +} + const MAX_RETRIES = 3; const RETRYABLE_5XX = new Set([500, 502, 503, 504]);