mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-05 22:43:59 +00:00
refactor(api): auto-inject discriminator value via generated MarshalJSON
Sealed-interface union variants now hardcode their wire discriminator
inside a generated MarshalJSON method instead of forcing callers to set
the field on every struct literal. Drops a class of silent-rejection
bugs where a typo in the discriminator slipped past the type checker
and through to Telegram, which then rejected the request with no
Go-side signal.
The discriminator field stays exported so incoming-message decoding,
type switches and debugging still see it. MarshalJSON wraps via a
function-local type alias and emits an outer field with the same json
tag; encoding/json (and goccy/go-json) resolve the outer field as the
shallower one and override whatever the caller wrote.
99 variants get MarshalJSON. 7 are skipped because their unions
dispatch structurally rather than by a string field: Message and
InaccessibleMessage (MaybeInaccessibleMessage, dispatched on date),
and the InputMessageContent family (InputTextMessageContent,
InputLocationMessageContent, InputVenueMessageContent,
InputContactMessageContent, InputInvoiceMessageContent — Telegram
identifies these by the presence of message_text / latitude /
phone_number / title etc.).
Discriminator extraction lives in the emitter (cmd/genapi/emitter.go).
Resolution: knownDiscriminators reverse-lookup for the 13 auto-decode
unions, then doc-string analysis ("must be X" / "always “X”")
of the variant's first required string field for marker-only unions
(BotCommandScope, InputMedia, InputPaidMedia, InputProfilePhoto,
InputStoryContent, InputPollMedia, InputPollOptionMedia,
InlineQueryResult, PassportElementError). Variants the emitter cannot
resolve a discriminator for are skipped silently rather than emitting
broken code.
Internal call-site cleanups: 4 manual discriminator assignments
removed (api/unionparam_test.go,
dispatch/filters/message/message_test.go, examples/inline/main.go ×2).
Regression tests added in api/marshaljson_variants_test.go covering
type-keyed variants, source-keyed variants, the override-user-typo
guarantee, round-trip preservation through UnmarshalChatMember, the
no-discriminator InputMessageContent path, and ride-along of
non-discriminator fields.
regen-from-fixture is deterministic across two consecutive runs;
go test -race / go vet / staticcheck all clean.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMarshalJSON_TypeDiscriminator_AutoInjected verifies the generated
|
||||
// MarshalJSON hardcodes the wire discriminator for a Type-keyed variant
|
||||
// even when the caller leaves the field zero.
|
||||
func TestMarshalJSON_TypeDiscriminator_AutoInjected(t *testing.T) {
|
||||
scope := &BotCommandScopeAllPrivateChats{}
|
||||
got, err := json.Marshal(scope)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"type":"all_private_chats"}`, string(got))
|
||||
}
|
||||
|
||||
// TestMarshalJSON_SourceDiscriminator_AutoInjected verifies the same
|
||||
// for variants that use a non-Type discriminator field. PassportElement
|
||||
// errors key on "source" instead.
|
||||
func TestMarshalJSON_SourceDiscriminator_AutoInjected(t *testing.T) {
|
||||
err := &PassportElementErrorDataField{
|
||||
Type: PassportElementErrorDataFieldTypePersonalDetails,
|
||||
FieldName: "first_name",
|
||||
DataHash: "abc123",
|
||||
Message: "bad data",
|
||||
}
|
||||
got, mErr := json.Marshal(err)
|
||||
require.NoError(t, mErr)
|
||||
require.JSONEq(t,
|
||||
`{"source":"data","type":"personal_details","field_name":"first_name","data_hash":"abc123","message":"bad data"}`,
|
||||
string(got),
|
||||
)
|
||||
}
|
||||
|
||||
// TestMarshalJSON_UserSuppliedDiscriminator_Overridden documents the
|
||||
// safety guarantee: a typo or stale value the caller pastes into the
|
||||
// struct literal is silently overridden by the generated MarshalJSON.
|
||||
// This is what saves callers from Telegram's "silent reject" failure
|
||||
// mode when a discriminator is wrong.
|
||||
func TestMarshalJSON_UserSuppliedDiscriminator_Overridden(t *testing.T) {
|
||||
scope := &BotCommandScopeAllPrivateChats{Type: "wrong"}
|
||||
got, err := json.Marshal(scope)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"type":"all_private_chats"}`, string(got))
|
||||
}
|
||||
|
||||
// TestMarshalJSON_RoundTrip confirms a marshal-then-unmarshal cycle
|
||||
// preserves user-supplied fields. Discriminator field is set on the
|
||||
// way out, read back on the way in — no data loss.
|
||||
//
|
||||
// Uses ChatMember (one of the auto-decode unions) so the round-trip
|
||||
// can route through the generated UnmarshalChatMember dispatcher.
|
||||
func TestMarshalJSON_RoundTrip(t *testing.T) {
|
||||
orig := &ChatMemberLeft{
|
||||
User: User{ID: 42, IsBot: false, FirstName: "alice"},
|
||||
}
|
||||
raw, err := json.Marshal(orig)
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := UnmarshalChatMember(raw)
|
||||
require.NoError(t, err)
|
||||
|
||||
round, ok := out.(*ChatMemberLeft)
|
||||
require.True(t, ok, "expected *ChatMemberLeft, got %T", out)
|
||||
require.Equal(t, ChatMemberLeftStatusLeft, round.Status)
|
||||
require.Equal(t, orig.User.ID, round.User.ID)
|
||||
require.Equal(t, orig.User.FirstName, round.User.FirstName)
|
||||
}
|
||||
|
||||
// TestMarshalJSON_InputMessageContent_NoDiscriminator confirms that
|
||||
// variants of InputMessageContent (the structurally-dispatched union
|
||||
// Telegram identifies by field presence, not by a "type" field) do
|
||||
// NOT get an injected discriminator. Their fields ride out as-is.
|
||||
func TestMarshalJSON_InputMessageContent_NoDiscriminator(t *testing.T) {
|
||||
content := &InputTextMessageContent{
|
||||
MessageText: "hello world",
|
||||
}
|
||||
got, err := json.Marshal(content)
|
||||
require.NoError(t, err)
|
||||
// No "type" field should appear; just message_text.
|
||||
require.JSONEq(t, `{"message_text":"hello world"}`, string(got))
|
||||
}
|
||||
|
||||
// TestMarshalJSON_NonDiscriminatorMembers_RidealongUnchanged verifies
|
||||
// the alias-embedding pattern: every non-discriminator field on the
|
||||
// variant marshals through the *alias and keeps its own json tag and
|
||||
// omitempty behaviour. Caption + ParseMode here exercise both
|
||||
// required-string-with-discriminator and optional-with-omitempty.
|
||||
func TestMarshalJSON_NonDiscriminatorMembers_RidealongUnchanged(t *testing.T) {
|
||||
media := &InputMediaPhoto{
|
||||
Media: "https://example.com/photo.jpg",
|
||||
Caption: "look",
|
||||
}
|
||||
got, err := json.Marshal(media)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t,
|
||||
`{"type":"photo","media":"https://example.com/photo.jpg","caption":"look"}`,
|
||||
string(got),
|
||||
)
|
||||
}
|
||||
+1584
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,7 @@ func TestSetMyCommands_BotCommandScope_NoPointerToInterface(t *testing.T) {
|
||||
// is `BotCommandScope` (interface), not `*BotCommandScope`.
|
||||
ok, err := SetMyCommands(context.Background(), bot, &SetMyCommandsParams{
|
||||
Commands: []BotCommand{{Command: "start", Description: "begin"}},
|
||||
Scope: &BotCommandScopeAllPrivateChats{Type: "all_private_chats"},
|
||||
Scope: &BotCommandScopeAllPrivateChats{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
Reference in New Issue
Block a user