feat(genapi): derive discriminators and decode RichText alternates

The generator gated all union decoder emission behind knownDiscriminators, a
hand-curated map of 13 entries. Any sealed union absent from that map received
no decoder, so decoding must fail. Bot API v10 added RichBlock and RichText,
which were absent. Roughly 8.6 percent of Telegram updates failed with HTTP 400.

The generator now derives discriminators from the specification instead of a
fixed list. A gate check fails the build when a union resolves no discriminator.
Eleven unions gain decoders that never had one: BotCommandScope, InputMedia,
InputPaidMedia, InputPollMedia, InputPollOptionMedia, InputProfilePhoto,
InputRichBlock, InputStoryContent, PassportElementError, RichBlock and RichText.

RichText can also appear as a bare string or an array of RichText. The scraper
now extracts these alternate shapes into the intermediate representation. The
emitter adds RichTextPlain and RichTextSequence. The decoder dispatches on the
first JSON byte before it reads any discriminator.

Unions without alternates must regenerate byte for byte. The golden test proves
this.

Claude-Session: https://claude.ai/code/session_01Fu2wXqGUrfVBi5aLafKk8U
This commit is contained in:
2026-09-15 10:59:48 +01:00
parent fa84f08b25
commit d666cda3b1
16 changed files with 4449 additions and 695 deletions
+210
View File
@@ -0,0 +1,210 @@
package api
import (
"testing"
json "github.com/goccy/go-json"
"github.com/stretchr/testify/require"
)
// TestUpdate_RichMessage_Decodes is a regression test for the Bot API v10
// rich_message decode failure: RichBlock (21 variants) and RichText (25
// variants) had no knownDiscriminators entry, so genapi never emitted
// UnmarshalRichBlock / UnmarshalRichText and any update whose message
// carried rich_message failed to decode — in production this rejected
// roughly 8.6% of Telegram updates with HTTP 400. Uses goccy/go-json, the
// codec production runs; plain encoding/json failed the same way.
func TestUpdate_RichMessage_Decodes(t *testing.T) {
body := `{"update_id":1,"message":{"message_id":7,"date":1789000000,"chat":{"id":-100123,"type":"supergroup"},` +
`"rich_message":{"blocks":[{"type":"paragraph","text":{"type":"bold","text":{"type":"anchor","name":"hi"}}}]}}}`
var u Update
require.NoError(t, json.Unmarshal([]byte(body), &u))
require.NotNil(t, u.Message)
require.NotNil(t, u.Message.RichMessage)
require.Len(t, u.Message.RichMessage.Blocks, 1)
para, ok := u.Message.RichMessage.Blocks[0].(*RichBlockParagraph)
require.True(t, ok, "blocks[0] must dispatch to *RichBlockParagraph, got %T", u.Message.RichMessage.Blocks[0])
bold, ok := para.Text.(*RichTextBold)
require.True(t, ok, "paragraph.text must dispatch to *RichTextBold, got %T", para.Text)
anchor, ok := bold.Text.(*RichTextAnchor)
require.True(t, ok, "bold.text must dispatch to *RichTextAnchor, got %T", bold.Text)
require.Equal(t, "hi", anchor.Name)
}
// TestUnmarshalRichBlock_UnknownType confirms an unrecognised discriminator
// value surfaces a clear error instead of silently decoding a zero-valued
// interface.
func TestUnmarshalRichBlock_UnknownType(t *testing.T) {
_, err := UnmarshalRichBlock([]byte(`{"type":"not_a_real_block"}`))
require.Error(t, err)
require.ErrorContains(t, err, "unknown type")
}
// TestUpdate_RichMessage_UnknownBlockType confirms the unknown-type error
// from UnmarshalRichBlock propagates all the way up through
// RichMessage.UnmarshalJSON to the top-level Update decode.
func TestUpdate_RichMessage_UnknownBlockType(t *testing.T) {
body := `{"update_id":1,"message":{"message_id":7,"date":1789000000,"chat":{"id":-100123,"type":"supergroup"},` +
`"rich_message":{"blocks":[{"type":"not_a_real_block"}]}}}`
var u Update
err := json.Unmarshal([]byte(body), &u)
require.Error(t, err)
require.ErrorContains(t, err, "unknown type")
}
// TestUpdate_RichMessage_PlainStringText is a regression test for the Bot
// API v10 RichText decode gap this task fixes. Telegram declares RichText
// as decodable from "either a String for plain text, an Array of
// RichText, or any of the following [object] types" — but the IR (and
// therefore UnmarshalRichText) only ever recorded the object variants, so
// a paragraph whose text is a bare JSON string failed with "invalid
// character '\"' looking for beginning of value". A plain-text paragraph
// is the most common real rich_message payload, so this was the
// production-facing half of the bug.
func TestUpdate_RichMessage_PlainStringText(t *testing.T) {
body := `{"update_id":1,"message":{"message_id":7,"date":1789000000,"chat":{"id":-100123,"type":"supergroup"},` +
`"rich_message":{"blocks":[{"type":"paragraph","text":"hello world"}]}}}`
var u Update
require.NoError(t, json.Unmarshal([]byte(body), &u))
require.NotNil(t, u.Message)
require.NotNil(t, u.Message.RichMessage)
require.Len(t, u.Message.RichMessage.Blocks, 1)
para, ok := u.Message.RichMessage.Blocks[0].(*RichBlockParagraph)
require.True(t, ok, "blocks[0] must dispatch to *RichBlockParagraph, got %T", u.Message.RichMessage.Blocks[0])
plain, ok := para.Text.(*RichTextPlain)
require.True(t, ok, "paragraph.text must dispatch to *RichTextPlain for a bare JSON string, got %T", para.Text)
require.Equal(t, "hello world", plain.Text)
}
// TestUpdate_RichMessage_ArrayText confirms Telegram's second declared
// RichText alternate — a bare JSON array of RichText — decodes into
// RichTextSequence, with each element dispatched recursively through
// UnmarshalRichText (an object variant in this case).
func TestUpdate_RichMessage_ArrayText(t *testing.T) {
body := `{"update_id":1,"message":{"message_id":7,"date":1789000000,"chat":{"id":-100123,"type":"supergroup"},` +
`"rich_message":{"blocks":[{"type":"paragraph","text":[` +
`{"type":"bold","text":"x"},{"type":"italic","text":"y"}` +
`]}]}}}`
var u Update
require.NoError(t, json.Unmarshal([]byte(body), &u))
para, ok := u.Message.RichMessage.Blocks[0].(*RichBlockParagraph)
require.True(t, ok)
seq, ok := para.Text.(*RichTextSequence)
require.True(t, ok, "paragraph.text must dispatch to *RichTextSequence for a JSON array, got %T", para.Text)
require.Len(t, seq.Items, 2)
bold, ok := seq.Items[0].(*RichTextBold)
require.True(t, ok, "sequence[0] must be *RichTextBold, got %T", seq.Items[0])
boldText, ok := bold.Text.(*RichTextPlain)
require.True(t, ok)
require.Equal(t, "x", boldText.Text)
italic, ok := seq.Items[1].(*RichTextItalic)
require.True(t, ok, "sequence[1] must be *RichTextItalic, got %T", seq.Items[1])
italicText, ok := italic.Text.(*RichTextPlain)
require.True(t, ok)
require.Equal(t, "y", italicText.Text)
}
// TestUpdate_RichMessage_ArrayText_MixedElements confirms an array whose
// elements mix the bare-string shape and the object shape dispatches
// each element independently — the array alternate and the string
// alternate compose, they aren't mutually exclusive per element.
func TestUpdate_RichMessage_ArrayText_MixedElements(t *testing.T) {
body := `{"update_id":1,"message":{"message_id":7,"date":1789000000,"chat":{"id":-100123,"type":"supergroup"},` +
`"rich_message":{"blocks":[{"type":"paragraph","text":[` +
`"plain segment",{"type":"bold","text":"bold segment"}` +
`]}]}}}`
var u Update
require.NoError(t, json.Unmarshal([]byte(body), &u))
para, ok := u.Message.RichMessage.Blocks[0].(*RichBlockParagraph)
require.True(t, ok)
seq, ok := para.Text.(*RichTextSequence)
require.True(t, ok)
require.Len(t, seq.Items, 2)
plain, ok := seq.Items[0].(*RichTextPlain)
require.True(t, ok, "sequence[0] must be *RichTextPlain, got %T", seq.Items[0])
require.Equal(t, "plain segment", plain.Text)
bold, ok := seq.Items[1].(*RichTextBold)
require.True(t, ok, "sequence[1] must be *RichTextBold, got %T", seq.Items[1])
boldText, ok := bold.Text.(*RichTextPlain)
require.True(t, ok)
require.Equal(t, "bold segment", boldText.Text)
}
// TestRichTextPlain_MarshalJSON_RoundTrip confirms the plain-text
// alternate marshals back to a bare JSON string (not an object), and
// that string decodes straight back through UnmarshalRichText.
func TestRichTextPlain_MarshalJSON_RoundTrip(t *testing.T) {
v := &RichTextPlain{Text: "hello"}
b, err := json.Marshal(v)
require.NoError(t, err)
require.JSONEq(t, `"hello"`, string(b))
decoded, err := UnmarshalRichText(b)
require.NoError(t, err)
got, ok := decoded.(*RichTextPlain)
require.True(t, ok)
require.Equal(t, "hello", got.Text)
}
// TestRichTextSequence_MarshalJSON_RoundTrip confirms the array
// alternate marshals back to a bare JSON array (not an object wrapping
// one), preserving each element's own wire shape, and round-trips
// through UnmarshalRichText.
func TestRichTextSequence_MarshalJSON_RoundTrip(t *testing.T) {
v := &RichTextSequence{
Items: []RichText{
&RichTextPlain{Text: "a"},
&RichTextBold{Text: &RichTextPlain{Text: "b"}},
},
}
b, err := json.Marshal(v)
require.NoError(t, err)
require.JSONEq(t, `["a",{"type":"bold","text":"b"}]`, string(b))
decoded, err := UnmarshalRichText(b)
require.NoError(t, err)
seq, ok := decoded.(*RichTextSequence)
require.True(t, ok)
require.Len(t, seq.Items, 2)
p0, ok := seq.Items[0].(*RichTextPlain)
require.True(t, ok)
require.Equal(t, "a", p0.Text)
b1, ok := seq.Items[1].(*RichTextBold)
require.True(t, ok)
p1, ok := b1.Text.(*RichTextPlain)
require.True(t, ok)
require.Equal(t, "b", p1.Text)
}
// TestUnmarshalRichText_UnrecognisedShape confirms a JSON value that is
// neither a string, an array, nor an object surfaces a clear error
// instead of silently producing a zero-valued interface.
func TestUnmarshalRichText_UnrecognisedShape(t *testing.T) {
_, err := UnmarshalRichText([]byte(`42`))
require.Error(t, err)
require.ErrorContains(t, err, "unrecognised JSON value")
}
+2005
View File
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/lukaszraczylo/go-telegram/internal/spec"
"github.com/stretchr/testify/require"
)
// TestUnionAlternates_NoAlternates confirms a union with no Alternates
// (the common case — every union except RichText) produces no synthetic
// variants.
func TestUnionAlternates_NoAlternates(t *testing.T) {
td := spec.TypeDecl{Name: "ChatMember", OneOf: []string{"ChatMemberOwner"}}
require.Nil(t, unionAlternates(td))
}
// TestUnionAlternates_StringAndArray confirms both alternate shapes map
// to the expected synthetic variant names, in declaration order.
func TestUnionAlternates_StringAndArray(t *testing.T) {
td := spec.TypeDecl{
Name: "RichText",
OneOf: []string{"RichTextBold"},
Alternates: []spec.Alternate{
{Shape: spec.AlternateString},
{Shape: spec.AlternateArray},
},
}
got := unionAlternates(td)
require.Equal(t, []alternateVariant{
{Name: "RichTextPlain", IsString: true},
{Name: "RichTextSequence", IsArray: true},
}, got)
}
// TestEmitTypes_UnionWithAlternates_EmitsShapeDispatcher builds a minimal
// synthetic API with one discriminated union carrying Alternates (mirrors
// RichText's shape without depending on the full real spec) and confirms
// genapi emits: the two synthetic variant structs, their marker methods
// and MarshalJSON, and a shape-dispatching Unmarshal<Union> that branches
// on the first non-whitespace byte before falling back to the existing
// discriminator switch.
func TestEmitTypes_UnionWithAlternates_EmitsShapeDispatcher(t *testing.T) {
api := &spec.API{
Types: []spec.TypeDecl{
{
Name: "Widget",
OneOf: []string{"WidgetBold"},
Alternates: []spec.Alternate{
{Shape: spec.AlternateString},
{Shape: spec.AlternateArray},
},
},
{
Name: "WidgetBold",
Fields: []spec.Field{
{Name: "Type", JSONName: "type", Required: true,
Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}, EnumValues: []string{"bold"}},
},
},
},
}
tmp := t.TempDir()
e := newEmitter(api, tmp)
require.NoError(t, e.emitTypes())
got, err := os.ReadFile(filepath.Join(tmp, "types.gen.go"))
require.NoError(t, err)
src := string(got)
// Synthetic variants exist and implement the sealed interface.
require.Contains(t, src, "type WidgetPlain struct")
require.Contains(t, src, "Text string")
require.Contains(t, src, "func (*WidgetPlain) isWidget() {}")
require.Contains(t, src, "type WidgetSequence struct")
require.Contains(t, src, "Items []Widget")
require.Contains(t, src, "func (*WidgetSequence) isWidget() {}")
// Round-trip MarshalJSON for both.
require.Contains(t, src, "func (v *WidgetPlain) MarshalJSON() ([]byte, error) {")
require.Contains(t, src, "return json.Marshal(v.Text)")
require.Contains(t, src, "func (v *WidgetSequence) MarshalJSON() ([]byte, error) {")
require.Contains(t, src, "return json.Marshal(v.Items)")
// Shape-dispatching UnmarshalWidget: trims, branches on '"', '[', '{'.
require.Contains(t, src, "func UnmarshalWidget(data []byte) (Widget, error) {")
require.Contains(t, src, `trimmed := bytes.TrimLeft(data, " \t\r\n")`)
require.Contains(t, src, "case '\"':")
require.Contains(t, src, "return &WidgetPlain{Text: s}, nil")
require.Contains(t, src, "case '[':")
require.Contains(t, src, "return &WidgetSequence{Items: items}, nil")
require.Contains(t, src, "case '{':")
// The pre-existing discriminator switch is preserved beneath the
// shape dispatch, unrecognised discriminator still a clear error.
require.Contains(t, src, `fmt.Errorf("Widget: unknown type %q", probe.V)`)
// The struct-holding-the-union case inherits shape dispatch for free:
// WidgetBold has no union-typed field itself, so nothing further to
// assert there beyond successful compilation-shaped source (covered
// by TestEmit_Types_FixtureGolden's gofmt round trip for the real
// fixture, and by the api-package tests for full decode behaviour).
require.False(t, strings.Contains(src, "WidgetPlainSequence"), "sanity: no accidental name collision")
}
// TestEmitTypes_UnionWithoutAlternates_Unaffected confirms a discriminated
// union with no Alternates emits the original (pre-alternates) shape:
// no synthetic variants, no shape dispatch, and the same "decodes a X
// from JSON by inspecting" doc comment wording as before this feature.
func TestEmitTypes_UnionWithoutAlternates_Unaffected(t *testing.T) {
api := &spec.API{
Types: []spec.TypeDecl{
{Name: "Widget", OneOf: []string{"WidgetBold"}},
{
Name: "WidgetBold",
Fields: []spec.Field{
{Name: "Type", JSONName: "type", Required: true,
Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}, EnumValues: []string{"bold"}},
},
},
},
}
tmp := t.TempDir()
e := newEmitter(api, tmp)
require.NoError(t, e.emitTypes())
got, err := os.ReadFile(filepath.Join(tmp, "types.gen.go"))
require.NoError(t, err)
src := string(got)
require.NotContains(t, src, "WidgetPlain")
require.NotContains(t, src, "WidgetSequence")
// The package-level `var _ = bytes.TrimLeft` import guard is always
// present (it keeps the "bytes" import valid even when no union in
// this API has alternates); what must NOT appear is the per-union
// shape-dispatch call site.
require.NotContains(t, src, "trimmed := bytes.TrimLeft(data,")
require.Contains(t, src, "// UnmarshalWidget decodes a Widget from JSON by inspecting the")
require.Contains(t, src, `// "type" field and dispatching to the correct concrete type.`)
}
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"testing"
"github.com/lukaszraczylo/go-telegram/internal/spec"
"github.com/stretchr/testify/require"
)
// TestDeriveDiscriminators_RichBlockAndRichText_FromRealSpec is a
// regression test for the Bot API v10 rich_message decode failure:
// RichBlock and RichText had no knownDiscriminators entry, so genapi
// never emitted UnmarshalRichBlock / UnmarshalRichText and any update
// whose message carried rich_message failed to decode. Derivation must
// pick "type" for both and cover every variant the spec declares.
//
// The expected variant counts are read from the spec rather than pinned
// as literals. Telegram adds variants in most releases - v10.3 took
// RichBlock from 21 to 24 and RichText from 25 to 26 - so a frozen
// number turns a routine regeneration into a spurious failure while
// testing nothing the coverage assertion does not already cover.
func TestDeriveDiscriminators_RichBlockAndRichText_FromRealSpec(t *testing.T) {
api, err := loadAPI("../../internal/spec/api.json")
require.NoError(t, err)
// declaredVariants returns how many concrete variants the spec lists
// for a union, so the assertions below track the spec instead of a
// hardcoded count.
declaredVariants := func(union string) int {
for i := range api.Types {
if api.Types[i].Name == union {
return len(api.Types[i].OneOf)
}
}
t.Fatalf("union %q not found in spec", union)
return 0
}
derived, _ := deriveDiscriminators(api)
rb, ok := derived["RichBlock"]
require.True(t, ok, "RichBlock must derive a discriminator")
require.Equal(t, "type", rb.Field)
require.Len(t, rb.Variants, declaredVariants("RichBlock"),
"every RichBlock variant declared in the spec must be covered")
require.Equal(t, "RichBlockParagraph", rb.Variants["paragraph"])
require.Equal(t, "RichBlockTable", rb.Variants["table"])
require.Equal(t, "RichBlockThinking", rb.Variants["thinking"])
rt, ok := derived["RichText"]
require.True(t, ok, "RichText must derive a discriminator")
require.Equal(t, "type", rt.Field)
require.Len(t, rt.Variants, declaredVariants("RichText"),
"every RichText variant declared in the spec must be covered")
require.Equal(t, "RichTextBold", rt.Variants["bold"])
require.Equal(t, "RichTextAnchorLink", rt.Variants["anchor_link"])
require.Equal(t, "RichTextReferenceLink", rt.Variants["reference_link"])
}
// TestMergeDiscriminators_HandCuratedOverridesDerived confirms a
// knownDiscriminators entry always wins the merge even when derivation
// would also have produced a (different) answer for the same union —
// hand-curation is the escape hatch for a union the generic rule gets
// wrong.
func TestMergeDiscriminators_HandCuratedOverridesDerived(t *testing.T) {
api := &spec.API{
Types: []spec.TypeDecl{
{Name: "Widget", OneOf: []string{"WidgetA", "WidgetB"}},
{Name: "WidgetA", Fields: []spec.Field{
{Name: "Type", JSONName: "type", Required: true,
Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}, EnumValues: []string{"a"}},
}},
{Name: "WidgetB", Fields: []spec.Field{
{Name: "Type", JSONName: "type", Required: true,
Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}, EnumValues: []string{"b"}},
}},
},
}
derived, ungated := deriveDiscriminators(api)
require.Empty(t, ungated)
require.Equal(t, discriminatorSpec{
Field: "type",
Variants: map[string]string{"a": "WidgetA", "b": "WidgetB"},
}, derived["Widget"])
// Inject a hand-curated override that disagrees with the derived
// spec, merge, and confirm the override wins outright.
override := discriminatorSpec{Field: "kind", Variants: map[string]string{"custom": "WidgetA"}}
knownDiscriminators["Widget"] = override
t.Cleanup(func() { delete(knownDiscriminators, "Widget") })
merged := mergeDiscriminators(derived)
require.Equal(t, override, merged["Widget"])
}
// TestCheckDiscriminatorGate_FailsOnUngatedInboundUnion confirms the
// build gate fails generation when a union Telegram can actually send
// has no derivable discriminator: the variants share no common
// Type/Status/Source field at all.
func TestCheckDiscriminatorGate_FailsOnUngatedInboundUnion(t *testing.T) {
api := &spec.API{
Types: []spec.TypeDecl{
{Name: "Widget", OneOf: []string{"WidgetA", "WidgetB"}},
{Name: "WidgetA", Fields: []spec.Field{
{Name: "Kind", JSONName: "kind", Required: true, Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}},
}},
{Name: "WidgetB", Fields: []spec.Field{
{Name: "Flavor", JSONName: "flavor", Required: true, Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}},
}},
// Container is a plain (non-Input) type with a field of the
// undiscriminated union — this is what makes Widget reachable
// from real inbound Telegram data.
{Name: "Container", Fields: []spec.Field{
{Name: "W", JSONName: "w", Type: spec.TypeRef{Kind: spec.KindNamed, Name: "Widget"}},
}},
},
}
err := checkDiscriminatorGate(api)
require.Error(t, err)
require.ErrorContains(t, err, "Widget")
require.ErrorContains(t, err, "WidgetA")
require.ErrorContains(t, err, "WidgetB")
}
// TestCheckDiscriminatorGate_SilentForInputOnlyUnion confirms the same
// undiscriminated union does NOT fail the build when its only reference
// is a field on an Input*-prefixed type — those are always constructed
// and marshalled by the caller, never decoded from a Telegram response.
func TestCheckDiscriminatorGate_SilentForInputOnlyUnion(t *testing.T) {
api := &spec.API{
Types: []spec.TypeDecl{
{Name: "Widget", OneOf: []string{"WidgetA", "WidgetB"}},
{Name: "WidgetA", Fields: []spec.Field{
{Name: "Kind", JSONName: "kind", Required: true, Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}},
}},
{Name: "WidgetB", Fields: []spec.Field{
{Name: "Flavor", JSONName: "flavor", Required: true, Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}},
}},
{Name: "InputContainer", Fields: []spec.Field{
{Name: "W", JSONName: "w", Type: spec.TypeRef{Kind: spec.KindNamed, Name: "Widget"}},
}},
},
}
require.NoError(t, checkDiscriminatorGate(api))
}
+359 -38
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
_ "embed"
"errors"
"fmt"
"github.com/goccy/go-json"
"go/format"
@@ -10,6 +11,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strings"
"text/template"
"github.com/lukaszraczylo/go-telegram/internal/spec"
@@ -77,9 +79,18 @@ type discriminatorSpec struct {
Variants map[string]string // discriminator value → concrete Go type name
}
// knownDiscriminators maps parent union name → discriminator spec.
// Used by the template helpers hasDiscriminator / discriminatorField /
// discriminatorMap to emit UnmarshalXxx helpers.
// knownDiscriminators is the hand-curated OVERRIDE table for sealed-union
// discriminators. deriveDiscriminators computes the same kind of map
// automatically from the IR for every union that has one (see
// mergeDiscriminators); an entry here always wins the merge regardless of
// what derivation would have produced. Use this table only for unions the
// generic rule can't handle — e.g. MaybeInaccessibleMessage's integer
// discriminator, which needs bespoke decode logic no discriminatorSpec
// can express (Field is "" and Variants is nil; the template emits a
// hand-coded Unmarshal via isMaybeInaccessibleMessage instead). Every
// other entry here exists only because it predates derivation — removing
// one and re-running `go test -run TestEmit -update ./cmd/genapi/...`
// should reproduce it byte-for-byte from deriveDiscriminators.
var knownDiscriminators = map[string]discriminatorSpec{
"ChatMember": {
Field: "status",
@@ -205,15 +216,24 @@ type emitter struct {
// template to emit per-variant MarshalJSON that hardcodes the
// discriminator so callers don't have to set it by hand.
variantDiscs map[string]variantDiscriminator
// discs is the merged union-level discriminator table: derivation
// output with knownDiscriminators layered on top as overrides (see
// mergeDiscriminators). Every codegen path that decides whether a
// union auto-decodes — and how — consults this instead of the
// package-level knownDiscriminators map, so a union qualifies the
// moment deriveDiscriminators can resolve it, hand-curated or not.
discs map[string]discriminatorSpec
}
func newEmitter(api *spec.API, outDir string) *emitter {
knownInterfaceTypes = buildUnionTypeSet(api)
derived, _ := deriveDiscriminators(api)
return &emitter{
api: api,
outDir: outDir,
enums: planEnums(api),
variantDiscs: variantDiscriminators(api),
discs: mergeDiscriminators(derived),
}
}
@@ -349,6 +369,262 @@ func parseDiscriminatorDoc(doc string) string {
return ""
}
// discriminatorFieldCandidates returns the Go field names to try, in
// priority order, when looking for a discriminator shared by every
// variant of a union: the hand-curated field from knownDiscriminators
// (resolved to its Go name via the first variant that declares it) when
// the union already has an override entry, then "Type", "Status",
// "Source" — the three names Telegram's docs consistently use for this
// purpose. Shared by planUnifiedUnionEnums (enum naming, enums.go) and
// deriveDiscriminators (decode dispatch, below) so the two never disagree
// on where to look.
func discriminatorFieldCandidates(unionName string, variants []*spec.TypeDecl) []string {
var candidates []string
seen := map[string]bool{}
add := func(name string) {
if name == "" || seen[name] {
return
}
seen[name] = true
candidates = append(candidates, name)
}
if ds, ok := knownDiscriminators[unionName]; ok && ds.Field != "" {
for _, v := range variants {
for _, f := range v.Fields {
if f.JSONName == ds.Field {
add(f.Name)
break
}
}
}
}
add("Type")
add("Status")
add("Source")
return candidates
}
// fieldByGoName returns the field named name on fields, or nil if none matches.
func fieldByGoName(fields []spec.Field, name string) *spec.Field {
for i := range fields {
if fields[i].Name == name {
return &fields[i]
}
}
return nil
}
// discriminatorValue extracts the wire-level value a variant field
// contributes to a discriminator: a single-entry EnumValues list (the
// scraper's detected value), or a doc-parsed "must be X" / "always “X”"
// literal via parseDiscriminatorDoc (the same extraction
// extractVariantDiscriminator uses for the first-field case). Returns ""
// when the field isn't a required string or carries no resolvable value.
func discriminatorValue(f spec.Field) string {
if !f.Required || f.Type.Kind != spec.KindPrimitive || f.Type.Name != "string" {
return ""
}
if len(f.EnumValues) == 1 {
return f.EnumValues[0]
}
return parseDiscriminatorDoc(f.Doc)
}
// deriveDiscriminators inspects every union TypeDecl (len(OneOf) > 0) in
// api and tries to pick a discriminator JSON field automatically, so the
// hand-maintained knownDiscriminators map no longer has to name every
// union that qualifies. A union qualifies when, for some candidate field
// (see discriminatorFieldCandidates), EVERY variant declares that field
// and discriminatorValue resolves a non-empty wire value for it, and
// those values are unique across variants.
//
// Returns the derived map plus the names of unions that did not qualify,
// in declaration order. Callers merge the derived map with
// knownDiscriminators (mergeDiscriminators) and decide what to do about
// the leftovers — rescue via a hand-curated override, or fail the build
// if the union is reachable from real Telegram data (checkDiscriminatorGate).
func deriveDiscriminators(api *spec.API) (map[string]discriminatorSpec, []string) {
typeByName := make(map[string]*spec.TypeDecl, len(api.Types))
for i := range api.Types {
typeByName[api.Types[i].Name] = &api.Types[i]
}
derived := make(map[string]discriminatorSpec, 32)
var ungated []string
for i := range api.Types {
u := &api.Types[i]
if len(u.OneOf) == 0 {
continue
}
variants := make([]*spec.TypeDecl, 0, len(u.OneOf))
resolved := true
for _, vName := range u.OneOf {
v, found := typeByName[vName]
if !found {
resolved = false
break
}
variants = append(variants, v)
}
if !resolved {
ungated = append(ungated, u.Name)
continue
}
ds, qualified := deriveUnionDiscriminator(u.Name, variants)
if !qualified {
ungated = append(ungated, u.Name)
continue
}
derived[u.Name] = ds
}
return derived, ungated
}
// deriveUnionDiscriminator tries each candidate Go field name in
// priority order and returns the first one where every variant declares
// that field with a resolvable, unique wire value.
func deriveUnionDiscriminator(unionName string, variants []*spec.TypeDecl) (discriminatorSpec, bool) {
for _, goName := range discriminatorFieldCandidates(unionName, variants) {
jsonField := ""
values := make(map[string]string, len(variants)) // wire value -> variant Go type name
qualifies := true
for _, v := range variants {
f := fieldByGoName(v.Fields, goName)
if f == nil {
qualifies = false
break
}
value := discriminatorValue(*f)
if value == "" {
qualifies = false
break
}
if jsonField == "" {
jsonField = f.JSONName
} else if f.JSONName != jsonField {
qualifies = false
break
}
if _, dup := values[value]; dup {
qualifies = false
break
}
values[value] = v.Name
}
if qualifies {
return discriminatorSpec{Field: jsonField, Variants: values}, true
}
}
return discriminatorSpec{}, false
}
// mergeDiscriminators layers the hand-curated knownDiscriminators table
// on top of derived: every entry deriveDiscriminators produces is used
// as-is unless knownDiscriminators also names that union, in which case
// the hand-curated entry wins outright (the whole discriminatorSpec, not
// a per-key merge).
func mergeDiscriminators(derived map[string]discriminatorSpec) map[string]discriminatorSpec {
merged := make(map[string]discriminatorSpec, len(derived)+len(knownDiscriminators))
for name, ds := range derived {
merged[name] = ds
}
for name, ds := range knownDiscriminators {
merged[name] = ds
}
return merged
}
// isInboundReachable reports whether Telegram can actually send data
// typed as the named union to the bot: as a field on some type that
// isn't itself send-only, or as a method's return value. "Input*"-named
// unions are excluded outright regardless of where they're referenced —
// by Telegram's own naming convention in this API, every such union is
// something the bot constructs and marshals, never something it decodes.
func isInboundReachable(api *spec.API, unionName string) bool {
if strings.HasPrefix(unionName, "Input") {
return false
}
for _, m := range api.Methods {
if typeRefIsUnion(m.Returns, api, unionName) {
return true
}
}
for _, t := range api.Types {
if strings.HasPrefix(t.Name, "Input") {
continue // send-only struct; its fields are never decoded either.
}
for _, f := range t.Fields {
if typeRefIsUnion(f.Type, api, unionName) {
return true
}
}
}
return false
}
// typeRefIsUnion reports whether tr resolves to the named union: directly,
// as an array element, or via an inline oneOf variant-set match against
// unionName's own OneOf list.
func typeRefIsUnion(tr spec.TypeRef, api *spec.API, unionName string) bool {
switch tr.Kind {
case spec.KindNamed:
return tr.Name == unionName
case spec.KindArray:
if tr.ElemType != nil {
return typeRefIsUnion(*tr.ElemType, api, unionName)
}
case spec.KindOneOf:
for i := range api.Types {
if api.Types[i].Name == unionName && matchesVariants(tr.Variants, api.Types[i].OneOf...) {
return true
}
}
}
return false
}
// checkDiscriminatorGate fails codegen when a union Telegram can actually
// send to the bot has no usable discriminator — neither derived nor
// hand-curated. Such a union decodes to a bare interface field that no
// JSON codec can ever fill (the RichBlock/RichText Bot API v10
// regression this generalisation fixes: both had 20+ variants and no
// knownDiscriminators entry, so genapi silently emitted no UnmarshalXxx
// helper at all). Send-only "Input*" unions are exempt — see
// isInboundReachable.
func checkDiscriminatorGate(api *spec.API) error {
_, ungated := deriveDiscriminators(api)
typeByName := make(map[string]*spec.TypeDecl, len(api.Types))
for i := range api.Types {
typeByName[api.Types[i].Name] = &api.Types[i]
}
var offending []string
for _, name := range ungated {
if _, overridden := knownDiscriminators[name]; overridden {
continue // hand-curated rescue, e.g. MaybeInaccessibleMessage.
}
if isInboundReachable(api, name) {
offending = append(offending, name)
}
}
if len(offending) == 0 {
return nil
}
var b strings.Builder
fmt.Fprintf(&b, "genapi: %d inbound union(s) have no derivable or hand-curated discriminator "+
"and would decode as an unfillable interface field:", len(offending))
for _, name := range offending {
fmt.Fprintf(&b, "\n - %s (variants: %s): no single field is present, required, and holds a unique non-empty wire value on every variant",
name, strings.Join(typeByName[name].OneOf, ", "))
}
return errors.New(b.String())
}
// knownInterfaceTypes is the full set of sealed-interface union type names
// (both auto-decoded ones in knownDiscriminators and marker-only ones from
// types with OneOf). Populated at emitter construction. goType and
@@ -359,7 +635,7 @@ var knownInterfaceTypes = map[string]bool{}
// emitTypes renders types.gen.go.
func (e *emitter) emitTypes() error {
t, err := template.New("types").Funcs(funcsWithDiscs(e.enums, e.variantDiscs)).Parse(typesTmpl)
t, err := template.New("types").Funcs(funcsWithDiscs(e.enums, e.variantDiscs, e.discs)).Parse(typesTmpl)
if err != nil {
return fmt.Errorf("parse types.tmpl: %w", err)
}
@@ -396,24 +672,28 @@ func loadAPI(path string) (*spec.API, error) {
}
// funcsWithDiscs returns the shared FuncMap with the variant
// discriminator helpers bound to discs. types.tmpl uses
// discriminator helpers bound to variantDiscs. types.tmpl uses
// variantDiscFor/variantHasDisc to emit per-variant MarshalJSON that
// hardcodes the wire discriminator value.
func funcsWithDiscs(plan *enumPlan, discs map[string]variantDiscriminator) template.FuncMap {
fm := funcs(plan)
// hardcodes the wire discriminator value. unionDiscs is the merged
// union-level table (see mergeDiscriminators), threaded into funcs.
func funcsWithDiscs(plan *enumPlan, variantDiscs map[string]variantDiscriminator, unionDiscs map[string]discriminatorSpec) template.FuncMap {
fm := funcs(plan, unionDiscs)
fm["variantHasDisc"] = func(name string) bool {
_, ok := discs[name]
_, ok := variantDiscs[name]
return ok
}
fm["variantDiscField"] = func(name string) string { return discs[name].JSONField }
fm["variantDiscGoField"] = func(name string) string { return discs[name].GoField }
fm["variantDiscValue"] = func(name string) string { return discs[name].Value }
fm["variantDiscField"] = func(name string) string { return variantDiscs[name].JSONField }
fm["variantDiscGoField"] = func(name string) string { return variantDiscs[name].GoField }
fm["variantDiscValue"] = func(name string) string { return variantDiscs[name].Value }
return fm
}
// funcs is the FuncMap shared across templates. plan is the resolved
// enum plan; pass nil only in unit tests that don't exercise enums.
func funcs(plan *enumPlan) template.FuncMap {
// enum plan; pass nil only in unit tests that don't exercise enums. discs
// is the merged union-level discriminator table (see
// mergeDiscriminators); pass nil (or knownDiscriminators directly) in
// unit tests that only exercise the hand-curated unions.
func funcs(plan *enumPlan, discs map[string]discriminatorSpec) template.FuncMap {
return template.FuncMap{
"goType": goType,
"goField": func(parent string, f spec.Field) string {
@@ -444,20 +724,22 @@ func funcs(plan *enumPlan) template.FuncMap {
return plan.All()
},
"enumConstName": constName,
// discriminator helpers for types.tmpl
"hasDiscriminator": func(name string) bool { s, ok := knownDiscriminators[name]; return ok && len(s.Variants) > 0 },
// discriminator helpers for types.tmpl — all consult the merged
// table (discs), not the package-level knownDiscriminators, so a
// derived-only union gates exactly like a hand-curated one.
"hasDiscriminator": func(name string) bool { s, ok := discs[name]; return ok && len(s.Variants) > 0 },
"isSealedUnionReturn": func(tr spec.TypeRef) bool {
if tr.Kind != spec.KindNamed {
return false
}
s, ok := knownDiscriminators[tr.Name]
s, ok := discs[tr.Name]
return ok && len(s.Variants) > 0
},
"isSealedUnionArrayReturn": func(tr spec.TypeRef) bool {
if tr.Kind != spec.KindArray || tr.ElemType == nil || tr.ElemType.Kind != spec.KindNamed {
return false
}
s, ok := knownDiscriminators[tr.ElemType.Name]
s, ok := discs[tr.ElemType.Name]
return ok && len(s.Variants) > 0
},
"sealedUnionElemName": func(tr spec.TypeRef) string {
@@ -467,12 +749,13 @@ func funcs(plan *enumPlan) template.FuncMap {
return ""
},
"isMaybeInaccessibleMessage": func(name string) bool { return name == "MaybeInaccessibleMessage" },
"discriminatorField": func(name string) string { return knownDiscriminators[name].Field },
"discriminatorMap": func(name string) map[string]string { return knownDiscriminators[name].Variants },
"discriminatorField": func(name string) string { return discs[name].Field },
"discriminatorMap": func(name string) map[string]string { return discs[name].Variants },
// union-field helpers for per-struct UnmarshalJSON emission
"unionFields": unionFieldsOf,
"isArrayUnion": func(tr spec.TypeRef) bool { return hasUnionElem(tr) },
"unionTypeName": func(tr spec.TypeRef) string { name, _ := unionTypeFor(tr); return name },
"unionFields": func(t spec.TypeDecl) []unionField { return unionFieldsOf(t, discs) },
"isArrayUnion": func(tr spec.TypeRef) bool { return hasUnionElem(tr, discs) },
"unionTypeName": func(tr spec.TypeRef) string { name, _ := unionTypeFor(tr, discs); return name },
"unionAlternates": unionAlternates,
}
}
@@ -645,7 +928,7 @@ func returnGoElem(tr spec.TypeRef) string {
// emitMethods renders methods.gen.go.
func (e *emitter) emitMethods() error {
t, err := template.New("methods").Funcs(funcs(e.enums)).Parse(methodsTmpl)
t, err := template.New("methods").Funcs(funcs(e.enums, e.discs)).Parse(methodsTmpl)
if err != nil {
return fmt.Errorf("parse methods.tmpl: %w", err)
}
@@ -662,7 +945,7 @@ func (e *emitter) emitMethods() error {
// emitEnums renders enums.gen.go.
func (e *emitter) emitEnums() error {
t, err := template.New("enums").Funcs(funcs(e.enums)).Parse(enumsTmpl)
t, err := template.New("enums").Funcs(funcs(e.enums, e.discs)).Parse(enumsTmpl)
if err != nil {
return fmt.Errorf("parse enums.tmpl: %w", err)
}
@@ -735,11 +1018,12 @@ type unionField struct {
}
// unionFieldsOf returns the subset of t.Fields whose type is a known
// discriminated union (directly or as array element).
func unionFieldsOf(t spec.TypeDecl) []unionField {
// discriminated union (directly or as array element), per discs — the
// merged union-level discriminator table (see mergeDiscriminators).
func unionFieldsOf(t spec.TypeDecl, discs map[string]discriminatorSpec) []unionField {
var out []unionField
for _, f := range t.Fields {
if u, ok := unionTypeFor(f.Type); ok {
if u, ok := unionTypeFor(f.Type, discs); ok {
out = append(out, unionField{Field: f, UnionName: u})
}
}
@@ -747,19 +1031,20 @@ func unionFieldsOf(t spec.TypeDecl) []unionField {
}
// unionTypeFor inspects a TypeRef and reports whether it (or its array
// element) is a known discriminated union. Returns the union name and true.
func unionTypeFor(tr spec.TypeRef) (string, bool) {
// element) is a known discriminated union in discs. Returns the union
// name and true.
func unionTypeFor(tr spec.TypeRef, discs map[string]discriminatorSpec) (string, bool) {
switch tr.Kind {
case spec.KindNamed:
if _, ok := knownDiscriminators[tr.Name]; ok {
if _, ok := discs[tr.Name]; ok {
return tr.Name, true
}
case spec.KindArray:
if tr.ElemType != nil {
return unionTypeFor(*tr.ElemType)
return unionTypeFor(*tr.ElemType, discs)
}
case spec.KindOneOf:
if u := unionNameByVariants(tr.Variants); u != "" {
if u := unionNameByVariants(tr.Variants, discs); u != "" {
return u, true
}
}
@@ -768,8 +1053,8 @@ func unionTypeFor(tr spec.TypeRef) (string, bool) {
// unionNameByVariants finds the parent union whose variant type names exactly
// match the given variant set (order-insensitive).
func unionNameByVariants(variants []string) string {
for parentName, ds := range knownDiscriminators {
func unionNameByVariants(variants []string, discs map[string]discriminatorSpec) string {
for parentName, ds := range discs {
wanted := make([]string, 0, len(ds.Variants))
for _, vt := range ds.Variants {
wanted = append(wanted, vt)
@@ -782,14 +1067,50 @@ func unionNameByVariants(variants []string) string {
}
// hasUnionElem reports whether tr is an array whose element type is a known union.
func hasUnionElem(tr spec.TypeRef) bool {
func hasUnionElem(tr spec.TypeRef, discs map[string]discriminatorSpec) bool {
if tr.Kind != spec.KindArray || tr.ElemType == nil {
return false
}
_, ok := unionTypeFor(*tr.ElemType)
_, ok := unionTypeFor(*tr.ElemType, discs)
return ok
}
// alternateVariant describes one synthetic sealed-interface variant the
// generator emits for a union's non-object wire shape (see
// spec.Alternate): a bare-JSON-string variant, or a variant holding a
// JSON array of the union itself. Exactly one of IsString / IsArray is
// set. types.tmpl emits the struct, marker method, and MarshalJSON for
// each, and Unmarshal<Union> dispatches to it by inspecting the first
// non-whitespace byte of the wire value.
type alternateVariant struct {
Name string // synthetic Go type name, e.g. "RichTextPlain"
IsString bool
IsArray bool
}
// unionAlternates maps a union TypeDecl's spec.Alternate entries (see
// cmd/scrape's extractAlternates) to the synthetic Go variant
// descriptors types.tmpl emits alongside the regular OneOf variants:
// "<Union>Plain" for the bare-string shape, "<Union>Sequence" for the
// array-of-<Union> shape. Returns nil for a union with no alternates,
// which is every union except RichText as of Bot API v10 — so this is a
// no-op for the rest of the type list.
func unionAlternates(t spec.TypeDecl) []alternateVariant {
if len(t.Alternates) == 0 {
return nil
}
out := make([]alternateVariant, 0, len(t.Alternates))
for _, a := range t.Alternates {
switch a.Shape {
case spec.AlternateString:
out = append(out, alternateVariant{Name: t.Name + "Plain", IsString: true})
case spec.AlternateArray:
out = append(out, alternateVariant{Name: t.Name + "Sequence", IsArray: true})
}
}
return out
}
// matchesVariants reports whether got equals want as a set (order-insensitive).
func matchesVariants(got []string, want ...string) bool {
if len(got) != len(want) {
@@ -1023,7 +1344,7 @@ func (e *emitter) emitTests() error {
unionTypes := buildUnionTypeSet(e.api)
// Add test-specific helpers to the shared func map.
fm := funcs(e.enums)
fm := funcs(e.enums, e.discs)
fm["sentinelValue"] = makeSentinelValue(unionTypes, e.enums)
fm["successResp"] = successResp
+4 -24
View File
@@ -271,32 +271,12 @@ func planUnifiedUnionEnums(api *spec.API) (map[string]enumDecl, map[string]strin
continue
}
// Build the candidate Go-name list. Priority order:
// Build the candidate Go-name list (shared with deriveDiscriminators
// in emitter.go so enum naming and decode dispatch never disagree
// on where to look). Priority order:
// 1. discriminator GoField from knownDiscriminators (resolved via JSONName);
// 2. "Type", "Status", "Source".
var candidateNames []string
seen := map[string]bool{}
add := func(name string) {
if name == "" || seen[name] {
return
}
seen[name] = true
candidateNames = append(candidateNames, name)
}
if ds, ok := knownDiscriminators[u.Name]; ok && ds.Field != "" {
// Resolve Go-name from the first variant whose field matches the JSON name.
for _, v := range variants {
for _, f := range v.Fields {
if f.JSONName == ds.Field {
add(f.Name)
break
}
}
}
}
for _, n := range []string{"Type", "Status", "Source"} {
add(n)
}
candidateNames := discriminatorFieldCandidates(u.Name, variants)
// Find the first candidate Go-name where every variant has a
// matching single-value string-enum field.
+15 -15
View File
@@ -280,7 +280,7 @@ func TestMultipartFieldEntry_Named_Optional(t *testing.T) {
func TestUnionTypeFor_DirectNamed(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindNamed, Name: "ChatMember"}
name, ok := unionTypeFor(tr)
name, ok := unionTypeFor(tr, knownDiscriminators)
require.True(t, ok)
require.Equal(t, "ChatMember", name)
}
@@ -288,26 +288,26 @@ func TestUnionTypeFor_DirectNamed(t *testing.T) {
func TestUnionTypeFor_Array(t *testing.T) {
elem := spec.TypeRef{Kind: spec.KindNamed, Name: "ChatMember"}
tr := spec.TypeRef{Kind: spec.KindArray, ElemType: &elem}
name, ok := unionTypeFor(tr)
name, ok := unionTypeFor(tr, knownDiscriminators)
require.True(t, ok)
require.Equal(t, "ChatMember", name)
}
func TestUnionTypeFor_ArrayNilElem(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindArray}
_, ok := unionTypeFor(tr)
_, ok := unionTypeFor(tr, knownDiscriminators)
require.False(t, ok)
}
func TestUnionTypeFor_NotUnion(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}
_, ok := unionTypeFor(tr)
_, ok := unionTypeFor(tr, knownDiscriminators)
require.False(t, ok)
}
func TestUnionTypeFor_Unknown(t *testing.T) {
tr := spec.TypeRef{Kind: spec.Kind(99)}
_, ok := unionTypeFor(tr)
_, ok := unionTypeFor(tr, knownDiscriminators)
require.False(t, ok)
}
@@ -321,12 +321,12 @@ func TestUnionNameByVariants_ChatMember(t *testing.T) {
"ChatMemberOwner", "ChatMemberAdministrator", "ChatMemberMember",
"ChatMemberRestricted", "ChatMemberLeft", "ChatMemberBanned",
}
name := unionNameByVariants(variants)
name := unionNameByVariants(variants, knownDiscriminators)
require.Equal(t, "ChatMember", name)
}
func TestUnionNameByVariants_Unknown(t *testing.T) {
name := unionNameByVariants([]string{"X", "Y", "Z"})
name := unionNameByVariants([]string{"X", "Y", "Z"}, knownDiscriminators)
require.Equal(t, "", name)
}
@@ -336,24 +336,24 @@ func TestUnionNameByVariants_Unknown(t *testing.T) {
func TestHasUnionElem_NonArray(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindNamed, Name: "ChatMember"}
require.False(t, hasUnionElem(tr))
require.False(t, hasUnionElem(tr, knownDiscriminators))
}
func TestHasUnionElem_ArrayNilElem(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindArray}
require.False(t, hasUnionElem(tr))
require.False(t, hasUnionElem(tr, knownDiscriminators))
}
func TestHasUnionElem_ArrayUnionElem(t *testing.T) {
elem := spec.TypeRef{Kind: spec.KindNamed, Name: "ChatMember"}
tr := spec.TypeRef{Kind: spec.KindArray, ElemType: &elem}
require.True(t, hasUnionElem(tr))
require.True(t, hasUnionElem(tr, knownDiscriminators))
}
func TestHasUnionElem_ArrayNonUnionElem(t *testing.T) {
elem := spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}
tr := spec.TypeRef{Kind: spec.KindArray, ElemType: &elem}
require.False(t, hasUnionElem(tr))
require.False(t, hasUnionElem(tr, knownDiscriminators))
}
// ---------------------------------------------------------------------------
@@ -369,7 +369,7 @@ func TestUnionFieldsOf_WithUnionField(t *testing.T) {
{Name: "Date", JSONName: "date", Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "int64"}},
},
}
uf := unionFieldsOf(td)
uf := unionFieldsOf(td, knownDiscriminators)
require.Len(t, uf, 2)
require.Equal(t, "ChatMember", uf[0].UnionName)
}
@@ -621,14 +621,14 @@ func TestUnionTypeFor_OneOfVariants(t *testing.T) {
"ChatMemberRestricted", "ChatMemberLeft", "ChatMemberBanned",
},
}
name, ok := unionTypeFor(tr)
name, ok := unionTypeFor(tr, knownDiscriminators)
require.True(t, ok)
require.Equal(t, "ChatMember", name)
}
func TestUnionTypeFor_OneOfNoMatch(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"Foo", "Bar"}}
_, ok := unionTypeFor(tr)
_, ok := unionTypeFor(tr, knownDiscriminators)
require.False(t, ok)
}
@@ -637,7 +637,7 @@ func TestUnionTypeFor_OneOfNoMatch(t *testing.T) {
// ---------------------------------------------------------------------------
func TestFuncs_HasExpectedKeys(t *testing.T) {
fm := funcs(nil)
fm := funcs(nil, nil)
require.NotNil(t, fm)
for _, key := range []string{"goType", "docComment", "returnGoType", "unionFields"} {
require.NotNil(t, fm[key], "funcs() missing key %q", key)
+3
View File
@@ -29,6 +29,9 @@ func run(input, outdir string) error {
if err != nil {
return fmt.Errorf("load api: %w", err)
}
if err := checkDiscriminatorGate(api); err != nil {
return err
}
if err := os.MkdirAll(outdir, 0o750); err != nil {
return err
}
+62 -3
View File
@@ -7,6 +7,7 @@
package api
import (
"bytes"
"github.com/goccy/go-json"
"fmt"
"io"
@@ -15,6 +16,7 @@ import (
var _ = io.Discard // keep import even if no fields use io
var _ = json.Marshal // keep import for UnmarshalXxx helpers
var _ = fmt.Errorf // keep import for UnmarshalXxx helpers
var _ = bytes.TrimLeft // keep import for shape-dispatching UnmarshalXxx helpers (unions with alternates)
{{range .Types}}
{{- $td := . -}}
@@ -22,6 +24,7 @@ var _ = fmt.Errorf // keep import for UnmarshalXxx helpers
// {{.Name}} is a union type. The following concrete variants implement
// it:
{{range .OneOf}}// - {{.}}
{{end}}{{range unionAlternates .}}// - {{.Name}} ({{if .IsString}}bare JSON string{{else}}JSON array of {{$td.Name}}{{end}})
{{end}}//
{{docComment .Doc -}}
type {{.Name}} interface{ is{{.Name}}() }
@@ -30,11 +33,67 @@ type {{.Name}} interface{ is{{.Name}}() }
// is{{$td.Name}} is the marker method that makes {{.}} implement {{$td.Name}}.
func (*{{.}}) is{{$td.Name}}() {}
{{end}}
{{range unionAlternates .}}
// {{.Name}} implements {{$td.Name}} for the {{if .IsString}}bare JSON string{{else}}JSON array of {{$td.Name}}{{end}} wire
// shape Telegram documents for this union, alongside its object variants.
type {{.Name}} struct {
{{if .IsString}} Text string
{{else}} Items []{{$td.Name}}
{{end}}}
// is{{$td.Name}} is the marker method that makes {{.Name}} implement {{$td.Name}}.
func (*{{.Name}}) is{{$td.Name}}() {}
{{if .IsString}}// MarshalJSON encodes {{.Name}} back to its bare JSON string wire shape.
func (v *{{.Name}}) MarshalJSON() ([]byte, error) {
return json.Marshal(v.Text)
}
{{else}}// MarshalJSON encodes {{.Name}} back to its bare JSON array wire shape.
func (v *{{.Name}}) MarshalJSON() ([]byte, error) {
return json.Marshal(v.Items)
}
{{end}}
{{end}}
{{if hasDiscriminator .Name}}
// Unmarshal{{.Name}} decodes a {{.Name}} from JSON by inspecting the
{{$alts := unionAlternates .}}{{if $alts}}// Unmarshal{{.Name}} decodes a {{.Name}} from JSON. Telegram documents
// this type as either a bare JSON string, a JSON array of {{.Name}}, or
// an object dispatched by its "{{discriminatorField .Name}}" field;
// leading whitespace is trimmed and the first remaining byte selects
// the shape.
{{else}}// Unmarshal{{.Name}} decodes a {{.Name}} from JSON by inspecting the
// "{{discriminatorField .Name}}" field and dispatching to the correct concrete type.
func Unmarshal{{.Name}}(data []byte) ({{.Name}}, error) {
var probe struct {
{{end}}func Unmarshal{{.Name}}(data []byte) ({{.Name}}, error) {
{{if $alts}} trimmed := bytes.TrimLeft(data, " \t\r\n")
if len(trimmed) == 0 {
return nil, fmt.Errorf("{{.Name}}: empty JSON value")
}
switch trimmed[0] {
{{range $alts}}{{if .IsString}} case '"':
var s string
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("{{$td.Name}}: decoding string: %w", err)
}
return &{{.Name}}{Text: s}, nil
{{else}} case '[':
var raws []json.RawMessage
if err := json.Unmarshal(data, &raws); err != nil {
return nil, fmt.Errorf("{{$td.Name}}: decoding array: %w", err)
}
items := make([]{{$td.Name}}, 0, len(raws))
for i, r := range raws {
v, err := Unmarshal{{$td.Name}}(r)
if err != nil {
return nil, fmt.Errorf("{{$td.Name}}: decoding array[%d]: %w", i, err)
}
items = append(items, v)
}
return &{{.Name}}{Items: items}, nil
{{end}}{{end}} case '{':
// fall through to the discriminator switch below.
default:
return nil, fmt.Errorf("{{.Name}}: unrecognised JSON value starting with %q", trimmed[0])
}
{{end}} var probe struct {
V string `json:"{{discriminatorField .Name}}"`
}
if err := json.Unmarshal(data, &probe); err != nil {
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"regexp"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
// altStringRE matches the scalar-string alternate clause Telegram uses
// for union types that can also decode as plain text on the wire, e.g.
// "a String for plain text". The trailing "for ..." clause is optional
// and, when present, is not captured — only the shape matters. "an"
// (rather than "a") is tolerated so a future rephrasing ("an String")
// doesn't silently stop matching, and the word boundary keeps "Strings"
// or "SubString" from misfiring.
var altStringRE = regexp.MustCompile(`(?i)\ban?\s+String\b`)
// altArrayRE builds the pattern that matches the self-referential array
// alternate clause Telegram uses for recursive union types, e.g. "an
// Array of RichText". Matching is anchored to the union's own name so a
// doc mentioning "an Array of" some other type is never mistaken for a
// shape alternate.
func altArrayRE(unionName string) *regexp.Regexp {
return regexp.MustCompile(`(?i)\ban?\s+Array\s+of\s+` + regexp.QuoteMeta(unionName) + `\b`)
}
// extractAlternates detects Telegram's "can be either a String for
// plain text, an Array of <Union>, or any of the following types:"
// phrasing on a union type's doc string and returns the non-object
// shapes it declares, in a fixed (string, then array) order regardless
// of how the sentence orders its clauses.
//
// Conservative by design: callers invoke this only for types that
// already have OneOf populated (see typeFromSection in scrape.go), and
// it only recognises the two shapes Telegram's docs currently use for
// this ("a String" and "an Array of <the union's own name>") — anything
// else in the doc is ignored, so free-text mentioning "String" or
// "Array" elsewhere in a union's description can't produce a false
// positive for a type this pattern wasn't written for. As of Bot API
// v10, RichText is the only union whose doc matches either pattern.
func extractAlternates(unionName, doc string) []spec.Alternate {
var alts []spec.Alternate
if altStringRE.MatchString(doc) {
alts = append(alts, spec.Alternate{Shape: spec.AlternateString})
}
if altArrayRE(unionName).MatchString(doc) {
alts = append(alts, spec.Alternate{Shape: spec.AlternateArray})
}
return alts
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"os"
"testing"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
func TestExtractAlternates_RichTextDoc(t *testing.T) {
// Exact doc string as scraped from the live Bot API v10 docs page
// (internal/spec/api.json / testdata/html/latest.html).
doc := "This object represents a rich formatted text. Currently, it can be either a String for plain text, " +
"an Array of RichText, or any of the following types:"
got := extractAlternates("RichText", doc)
require.Equal(t, []spec.Alternate{
{Shape: spec.AlternateString},
{Shape: spec.AlternateArray},
}, got)
}
func TestExtractAlternates_OrderIndependent(t *testing.T) {
// Same two clauses, reversed, plus an extra clause in between —
// order and surrounding prose must not matter.
doc := "Currently, it can be either an Array of RichText, something else entirely, a String for plain text, " +
"or any of the following types:"
got := extractAlternates("RichText", doc)
require.Equal(t, []spec.Alternate{
{Shape: spec.AlternateString},
{Shape: spec.AlternateArray},
}, got)
}
func TestExtractAlternates_ObjectOnlyUnion_NoAlternates(t *testing.T) {
// RichBlock's doc is object-only: no String/Array alternate clause.
doc := "This object represents a block in a rich formatted message. Currently, it can be any of the following types:"
got := extractAlternates("RichBlock", doc)
require.Nil(t, got)
}
func TestExtractAlternates_ArrayOfDifferentType_NotSelfReferential(t *testing.T) {
// "an Array of X" only counts when X is the union's own name — an
// array of some other type mentioned in the doc must not match.
doc := "This object can be either an Array of PhotoSize or any of the following types:"
got := extractAlternates("RichText", doc)
require.Nil(t, got)
}
func TestExtractAlternates_StringOnly(t *testing.T) {
doc := "This object can be either a String or any of the following types:"
got := extractAlternates("Widget", doc)
require.Equal(t, []spec.Alternate{{Shape: spec.AlternateString}}, got)
}
func TestExtractAlternates_PlainTypeDoc_NoFalsePositive(t *testing.T) {
// Ordinary field/type prose mentioning neither shape must not match.
doc := "This object represents a chat photo."
got := extractAlternates("ChatPhoto", doc)
require.Nil(t, got)
}
// TestTypeFromSection_RichText_RealSnapshot grounds the extraction in the
// real pinned docs snapshot (not just a literal fixture string): scraping
// testdata/html/latest.html end-to-end must produce a RichText TypeDecl
// with both alternates, matching the same live text that broke decoding
// of plain-text and array-shaped rich_message payloads.
func TestTypeFromSection_RichText_RealSnapshot(t *testing.T) {
htmlBytes, err := os.ReadFile("../../testdata/html/latest.html")
require.NoError(t, err)
api, err := scrape(htmlBytes)
require.NoError(t, err)
var richText *spec.TypeDecl
for i := range api.Types {
if api.Types[i].Name == "RichText" {
richText = &api.Types[i]
break
}
}
require.NotNil(t, richText, "RichText type must be present in the real docs snapshot")
require.Equal(t, []spec.Alternate{
{Shape: spec.AlternateString},
{Shape: spec.AlternateArray},
}, richText.Alternates)
// RichBlock is the control: object-only union, no alternates.
var richBlock *spec.TypeDecl
for i := range api.Types {
if api.Types[i].Name == "RichBlock" {
richBlock = &api.Types[i]
break
}
}
require.NotNil(t, richBlock)
require.Nil(t, richBlock.Alternates)
}
+4
View File
@@ -38,6 +38,10 @@ func typeFromSection(s section) spec.TypeDecl {
} else if len(s.Lists) > 0 {
// Union: extract variant names from <li><a>...</a></li>.
td.OneOf = extractListLinks(s.Lists[0])
// Some unions (e.g. RichText) also declare non-object wire shapes
// ("a String for plain text", "an Array of RichText") in the same
// sentence as the object-variant list; extract those too.
td.Alternates = extractAlternates(td.Name, td.Doc)
}
return td
}
+1302 -606
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -13717,6 +13717,14 @@
"RichTextAnchorLink",
"RichTextReference",
"RichTextReferenceLink"
],
"alternates": [
{
"shape": "string"
},
{
"shape": "array"
}
]
},
{
+25
View File
@@ -24,6 +24,31 @@ type TypeDecl struct {
// OneOf, when non-empty, indicates this type is a union and lists the concrete variant type names.
// Variants are emitted as concrete structs implementing a sealed interface.
OneOf []string `json:"one_of,omitempty"`
// Alternates lists non-object wire shapes this union may also decode
// as, in addition to the object variants in OneOf. Only populated for
// unions whose doc declares them (Telegram's "can be either a String
// for plain text, an Array of X, or any of the following types:"
// phrasing) — as of Bot API v10 that's RichText alone, so every other
// type's serialised IR is unaffected by this field's existence.
Alternates []Alternate `json:"alternates,omitempty"`
}
// AlternateShape enumerates the non-object wire shapes a union's
// Alternates field records.
type AlternateShape string
const (
// AlternateString: the union may decode from a bare JSON string.
AlternateString AlternateShape = "string"
// AlternateArray: the union may decode from a JSON array whose
// elements are themselves the same union type.
AlternateArray AlternateShape = "array"
)
// Alternate records one non-object shape a union type may appear as on
// the wire, beyond its OneOf object variants.
type Alternate struct {
Shape AlternateShape `json:"shape"`
}
// MethodDecl describes a Telegram API method.
+2
View File
@@ -7,6 +7,7 @@
package api
import (
"bytes"
"fmt"
"github.com/goccy/go-json"
"io"
@@ -15,6 +16,7 @@ import (
var _ = io.Discard // keep import even if no fields use io
var _ = json.Marshal // keep import for UnmarshalXxx helpers
var _ = fmt.Errorf // keep import for UnmarshalXxx helpers
var _ = bytes.TrimLeft // keep import for shape-dispatching UnmarshalXxx helpers (unions with alternates)
// This object represents a Telegram user or bot.
type User struct {