Initial release of go-telegram

A fully-generated, strongly-typed Go client for the Telegram Bot API.

* 176 methods + 301 types generated from Bot API v10.0
* 1408 auto-generated tests (8 scenarios per method)
* Typed unions throughout — no 'any' in the public surface
* Pluggable HTTP transport and JSON codec (default goccy/go-json)
* Built-in retry middleware honouring Telegram's retry_after
* Generic dispatcher with filters and conversation handlers
* Self-verifying codegen pipeline (regen → audit → emit → run tests)
* 14 example bots covering common patterns
This commit is contained in:
2026-05-09 13:09:27 +01:00
commit ac7cae8fa7
164 changed files with 100239 additions and 0 deletions
+749
View File
@@ -0,0 +1,749 @@
package main
import (
"bytes"
_ "embed"
"fmt"
"github.com/goccy/go-json"
"go/format"
"os"
"path/filepath"
"sort"
"text/template"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
//go:embed types.tmpl
var typesTmpl string
//go:embed methods.tmpl
var methodsTmpl string
//go:embed enums.tmpl
var enumsTmpl string
//go:embed tests.tmpl
var testsTmpl string
// runtimeTypes lists types that are intentionally hand-coded and must not be
// emitted by the code generator. Skipping them prevents collisions between
// generated and hand-coded definitions.
var runtimeTypes = map[string]bool{
"InputFile": true,
"ResponseParameters": true,
"ChatID": true,
"MessageOrBool": true,
}
// discriminatorSpec describes how to decode a sealed-interface union by
// peeking at a single JSON field.
type discriminatorSpec struct {
Field string // JSON field name to peek at
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.
var knownDiscriminators = map[string]discriminatorSpec{
"ChatMember": {
Field: "status",
Variants: map[string]string{
"creator": "ChatMemberOwner",
"administrator": "ChatMemberAdministrator",
"member": "ChatMemberMember",
"restricted": "ChatMemberRestricted",
"left": "ChatMemberLeft",
"kicked": "ChatMemberBanned",
},
},
"MessageOrigin": {
Field: "type",
Variants: map[string]string{
"user": "MessageOriginUser",
"hidden_user": "MessageOriginHiddenUser",
"chat": "MessageOriginChat",
"channel": "MessageOriginChannel",
},
},
"ReactionType": {
Field: "type",
Variants: map[string]string{
"emoji": "ReactionTypeEmoji",
"custom_emoji": "ReactionTypeCustomEmoji",
"paid": "ReactionTypePaid",
},
},
"PaidMedia": {
Field: "type",
Variants: map[string]string{
"preview": "PaidMediaPreview",
"photo": "PaidMediaPhoto",
"video": "PaidMediaVideo",
},
},
"BackgroundType": {
Field: "type",
Variants: map[string]string{
"fill": "BackgroundTypeFill",
"wallpaper": "BackgroundTypeWallpaper",
"pattern": "BackgroundTypePattern",
"chat_theme": "BackgroundTypeChatTheme",
},
},
"BackgroundFill": {
Field: "type",
Variants: map[string]string{
"solid": "BackgroundFillSolid",
"gradient": "BackgroundFillGradient",
"freeform_gradient": "BackgroundFillFreeformGradient",
},
},
"ChatBoostSource": {
Field: "source",
Variants: map[string]string{
"premium": "ChatBoostSourcePremium",
"gift_code": "ChatBoostSourceGiftCode",
"giveaway": "ChatBoostSourceGiveaway",
},
},
"RevenueWithdrawalState": {
Field: "type",
Variants: map[string]string{
"pending": "RevenueWithdrawalStatePending",
"succeeded": "RevenueWithdrawalStateSucceeded",
"failed": "RevenueWithdrawalStateFailed",
},
},
"TransactionPartner": {
Field: "type",
Variants: map[string]string{
"fragment": "TransactionPartnerFragment",
"user": "TransactionPartnerUser",
"telegram_ads": "TransactionPartnerTelegramAds",
"telegram_api": "TransactionPartnerTelegramApi",
"other": "TransactionPartnerOther",
},
},
"MenuButton": {
Field: "type",
Variants: map[string]string{
"commands": "MenuButtonCommands",
"web_app": "MenuButtonWebApp",
"default": "MenuButtonDefault",
},
},
"OwnedGift": {
Field: "type",
Variants: map[string]string{
"regular": "OwnedGiftRegular",
"unique": "OwnedGiftUnique",
},
},
"StoryAreaType": {
Field: "type",
Variants: map[string]string{
"location": "StoryAreaTypeLocation",
"suggested_reaction": "StoryAreaTypeSuggestedReaction",
"link": "StoryAreaTypeLink",
"weather": "StoryAreaTypeWeather",
"unique_gift": "StoryAreaTypeUniqueGift",
},
},
// MaybeInaccessibleMessage uses an integer discriminator (date field).
// Variants is nil — the standard template block is skipped; a
// hand-coded UnmarshalMaybeInaccessibleMessage is emitted instead.
"MaybeInaccessibleMessage": {
Field: "",
Variants: nil,
},
}
// emitter renders Go source from a spec.API IR.
type emitter struct {
api *spec.API
outDir string
}
func newEmitter(api *spec.API, outDir string) *emitter {
return &emitter{api: api, outDir: outDir}
}
// emitTypes renders types.gen.go.
func (e *emitter) emitTypes() error {
t, err := template.New("types").Funcs(funcs()).Parse(typesTmpl)
if err != nil {
return fmt.Errorf("parse types.tmpl: %w", err)
}
filtered := *e.api
filtered.Types = nil
for _, typ := range e.api.Types {
if !runtimeTypes[typ.Name] {
filtered.Types = append(filtered.Types, typ)
}
}
var buf bytes.Buffer
if execErr := t.Execute(&buf, &filtered); execErr != nil {
return fmt.Errorf("execute types.tmpl: %w", execErr)
}
src, err := format.Source(buf.Bytes())
if err != nil {
// Surface the unformatted output so debugging is possible.
return fmt.Errorf("gofmt types.gen.go: %w\n--- unformatted ---\n%s", err, buf.String())
}
return os.WriteFile(filepath.Join(e.outDir, "types.gen.go"), src, 0o600)
}
// loadAPI reads and decodes the IR JSON.
func loadAPI(path string) (*spec.API, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var api spec.API
if err := json.Unmarshal(data, &api); err != nil {
return nil, err
}
return &api, nil
}
// funcs is the FuncMap shared across templates.
func funcs() template.FuncMap {
return template.FuncMap{
"goType": goType,
"goField": goField,
"docComment": docComment,
"isOptional": func(f spec.Field) bool { return !f.Required },
"not": func(b bool) bool { return !b },
"title": title,
"isFileField": isFileField,
"fileCheck": fileCheck,
"multipartFieldEntry": multipartFieldEntry,
"multipartFileEntry": multipartFileEntry,
"returnGoType": returnGoType,
// discriminator helpers for types.tmpl
"hasDiscriminator": func(name string) bool { s, ok := knownDiscriminators[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]
return ok && len(s.Variants) > 0
},
"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 },
// 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 },
}
}
// title upper-cases the first byte of s (ASCII only — all Telegram method names are ASCII).
func title(s string) string {
if s == "" {
return ""
}
r := s[0]
if r >= 'a' && r <= 'z' {
r = r - 'a' + 'A'
}
return string(r) + s[1:]
}
// isFileField reports whether the field carries an InputFile.
func isFileField(f spec.Field) bool {
return mentionsInputFileTr(f.Type)
}
func mentionsInputFileTr(tr spec.TypeRef) bool {
switch tr.Kind {
case spec.KindNamed:
return tr.Name == "InputFile"
case spec.KindArray:
if tr.ElemType != nil {
return mentionsInputFileTr(*tr.ElemType)
}
case spec.KindOneOf:
for _, v := range tr.Variants {
if v == "InputFile" {
return true
}
}
}
return false
}
// fileCheck returns the HasFile guard line for a file-carrying field.
// Both named InputFile and InputFile-or-String oneOf fields are now *InputFile,
// so no type assertion is needed in either case.
func fileCheck(f spec.Field) string {
return fmt.Sprintf("\tif p.%s != nil && p.%s.IsLocalUpload() { return true }\n", f.Name, f.Name)
}
// multipartFileEntry returns the MultipartFiles append block for a file field.
// Both named InputFile and InputFile-or-String oneOf fields are now *InputFile,
// so the same code works for both cases.
func multipartFileEntry(f spec.Field) string {
jsonName := f.JSONName
return fmt.Sprintf(
"\tif p.%s != nil && p.%s.IsLocalUpload() {\n\t\tname := p.%s.Filename\n\t\tif name == \"\" { name = %q }\n\t\tfiles = append(files, client.MultipartFile{FieldName: %q, Filename: name, Reader: p.%s.Reader})\n\t}\n",
f.Name, f.Name, f.Name, jsonName, jsonName, f.Name)
}
// multipartFieldEntry generates the line that adds f to the multipart map.
// Required scalar fields go in unconditionally; optional ones go in only
// when non-zero/non-empty.
func multipartFieldEntry(f spec.Field) string {
switch f.Type.Kind {
case spec.KindPrimitive:
switch f.Type.Name {
case "int64":
if f.Required {
return fmt.Sprintf("\tout[%q] = strconv.FormatInt(p.%s, 10)\n", f.JSONName, f.Name)
}
return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatInt(*p.%s, 10) }\n", f.Name, f.JSONName, f.Name)
case "string":
if f.Required {
return fmt.Sprintf("\tout[%q] = p.%s\n", f.JSONName, f.Name)
}
return fmt.Sprintf("\tif p.%s != \"\" { out[%q] = p.%s }\n", f.Name, f.JSONName, f.Name)
case "bool":
if f.Required {
return fmt.Sprintf("\tout[%q] = strconv.FormatBool(p.%s)\n", f.JSONName, f.Name)
}
return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatBool(*p.%s) }\n", f.Name, f.JSONName, f.Name)
case "float64":
if f.Required {
return fmt.Sprintf("\tout[%q] = strconv.FormatFloat(p.%s, 'f', -1, 64)\n", f.JSONName, f.Name)
}
return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatFloat(*p.%s, 'f', -1, 64) }\n", f.Name, f.JSONName, f.Name)
}
case spec.KindOneOf:
// Integer-or-String → ChatID: use .String() wire form.
if matchesVariants(f.Type.Variants, "int64", "string") {
if f.Required {
return fmt.Sprintf("\tout[%q] = p.%s.String()\n", f.JSONName, f.Name)
}
return fmt.Sprintf("\tif !p.%s.IsZero() { out[%q] = p.%s.String() }\n", f.Name, f.JSONName, f.Name)
}
// InputFile-or-String → *InputFile: non-upload branch sends PathOrID.
if matchesVariants(f.Type.Variants, "InputFile", "string") {
return fmt.Sprintf("\tif p.%s != nil && !p.%s.IsLocalUpload() && p.%s.PathOrID != \"\" { out[%q] = p.%s.PathOrID }\n",
f.Name, f.Name, f.Name, f.JSONName, f.Name)
}
// Sealed-interface unions — JSON-marshal.
if f.Required {
return fmt.Sprintf("\tif b, _ := json.Marshal(p.%s); len(b) > 0 && string(b) != \"null\" { out[%q] = string(b) }\n", f.Name, f.JSONName)
}
return fmt.Sprintf("\tif p.%s != nil { if b, _ := json.Marshal(p.%s); len(b) > 0 && string(b) != \"null\" { out[%q] = string(b) } }\n", f.Name, f.Name, f.JSONName)
}
// Named or array: fall back to JSON-marshal to JSON string.
if f.Required {
return fmt.Sprintf("\tif b, _ := json.Marshal(p.%s); len(b) > 0 { out[%q] = string(b) }\n", f.Name, f.JSONName)
}
return fmt.Sprintf("\tif p.%s != nil { if b, _ := json.Marshal(p.%s); len(b) > 0 { out[%q] = string(b) } }\n", f.Name, f.Name, f.JSONName)
}
func returnGoType(tr spec.TypeRef) string {
switch tr.Kind {
case spec.KindPrimitive:
return tr.Name
case spec.KindNamed:
// Sealed-interface unions are returned by interface value, not pointer
// (you can't take a pointer to an interface in any useful way; the
// generated UnmarshalXxx returns the interface directly).
if _, ok := knownDiscriminators[tr.Name]; ok {
return tr.Name
}
// MessageOrBool is a hand-coded runtime wrapper — pointer return.
return "*" + tr.Name
case spec.KindArray:
if tr.ElemType == nil {
return "[]any"
}
return "[]" + returnGoElem(*tr.ElemType)
case spec.KindOneOf:
// Integer-or-String return (rare but possible).
if matchesVariants(tr.Variants, "int64", "string") {
return "ChatID"
}
return "any"
}
return "any"
}
func returnGoElem(tr spec.TypeRef) string {
switch tr.Kind {
case spec.KindPrimitive:
return tr.Name
case spec.KindNamed:
return tr.Name
case spec.KindArray:
if tr.ElemType == nil {
return "any"
}
return "[]" + returnGoElem(*tr.ElemType)
}
return "any"
}
// emitMethods renders methods.gen.go.
func (e *emitter) emitMethods() error {
t, err := template.New("methods").Funcs(funcs()).Parse(methodsTmpl)
if err != nil {
return fmt.Errorf("parse methods.tmpl: %w", err)
}
var buf bytes.Buffer
if execErr := t.Execute(&buf, e.api); execErr != nil {
return fmt.Errorf("execute methods.tmpl: %w", execErr)
}
src, err := format.Source(buf.Bytes())
if err != nil {
return fmt.Errorf("gofmt methods.gen.go: %w\n--- unformatted ---\n%s", err, buf.String())
}
return os.WriteFile(filepath.Join(e.outDir, "methods.gen.go"), src, 0o600)
}
// emitEnums renders enums.gen.go.
func (e *emitter) emitEnums() error {
t, err := template.New("enums").Funcs(funcs()).Parse(enumsTmpl)
if err != nil {
return fmt.Errorf("parse enums.tmpl: %w", err)
}
var buf bytes.Buffer
if execErr := t.Execute(&buf, e.api); execErr != nil {
return fmt.Errorf("execute enums.tmpl: %w", execErr)
}
src, err := format.Source(buf.Bytes())
if err != nil {
return fmt.Errorf("gofmt enums.gen.go: %w\n--- unformatted ---\n%s", err, buf.String())
}
return os.WriteFile(filepath.Join(e.outDir, "enums.gen.go"), src, 0o600)
}
// goType returns the Go type expression for a TypeRef.
// Optional fields use pointer types for primitives and named types,
// or rely on omitempty for slices and maps. parameter `optional` controls
// whether to wrap pointer-style.
func goType(tr spec.TypeRef, optional bool) string {
switch tr.Kind {
case spec.KindPrimitive:
if optional && (tr.Name == "bool" || tr.Name == "int64" || tr.Name == "float64") {
return "*" + tr.Name
}
return tr.Name
case spec.KindNamed:
// Named types are always pointer-optional when optional, except:
// 1. Union (interface) types — they are naturally nil-able; pointer-to-interface is invalid.
// 2. InputFile is always pointer-typed even when required: the
// multipart helpers (fileCheck, multipartFileEntry) call
// f.IsLocalUpload() and dereference Reader, both of which
// expect a pointer receiver.
if _, isUnion := knownDiscriminators[tr.Name]; isUnion {
// Interface type — never add *.
return tr.Name
}
if optional || tr.Name == "InputFile" {
return "*" + tr.Name
}
return tr.Name
case spec.KindArray:
if tr.ElemType == nil {
return "[]any"
}
// Inside slices, the element shape is its own thing — never wrap
// the element in a pointer just because the field is optional.
return "[]" + goType(*tr.ElemType, false)
case spec.KindOneOf:
// Integer-or-String: typed ChatID wrapper.
if matchesVariants(tr.Variants, "int64", "string") {
if optional {
return "*ChatID"
}
return "ChatID"
}
// InputFile-or-String: *InputFile runtime helper handles both.
if matchesVariants(tr.Variants, "InputFile", "string") {
return "*InputFile"
}
// All-named variants sealed interface: fall back to interface.
return "any"
}
return "any"
}
// unionField pairs a struct field with the name of its union type.
type unionField struct {
Field spec.Field
UnionName string // e.g. "ChatMember"
}
// 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 {
var out []unionField
for _, f := range t.Fields {
if u, ok := unionTypeFor(f.Type); ok {
out = append(out, unionField{Field: f, UnionName: u})
}
}
return out
}
// 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) {
switch tr.Kind {
case spec.KindNamed:
if _, ok := knownDiscriminators[tr.Name]; ok {
return tr.Name, true
}
case spec.KindArray:
if tr.ElemType != nil {
return unionTypeFor(*tr.ElemType)
}
case spec.KindOneOf:
if u := unionNameByVariants(tr.Variants); u != "" {
return u, true
}
}
return "", false
}
// 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 {
wanted := make([]string, 0, len(ds.Variants))
for _, vt := range ds.Variants {
wanted = append(wanted, vt)
}
if matchesVariants(variants, wanted...) {
return parentName
}
}
return ""
}
// hasUnionElem reports whether tr is an array whose element type is a known union.
func hasUnionElem(tr spec.TypeRef) bool {
if tr.Kind != spec.KindArray || tr.ElemType == nil {
return false
}
_, ok := unionTypeFor(*tr.ElemType)
return ok
}
// matchesVariants reports whether got equals want as a set (order-insensitive).
func matchesVariants(got []string, want ...string) bool {
if len(got) != len(want) {
return false
}
seen := make(map[string]int, len(got))
for _, g := range got {
seen[g]++
}
for _, w := range want {
seen[w]--
}
for _, v := range seen {
if v != 0 {
return false
}
}
return true
}
// goField returns the Go struct-field declaration for a Field.
func goField(f spec.Field) string {
tag := fmt.Sprintf("`json:%q`", f.JSONName+omitempty(f))
return fmt.Sprintf("%s %s %s", f.Name, goType(f.Type, !f.Required), tag)
}
func omitempty(f spec.Field) string {
if f.Required {
return ""
}
return ",omitempty"
}
// docComment converts a doc string into a Go-style block comment with
// a leading "// " on each line.
func docComment(s string) string {
if s == "" {
return ""
}
var buf bytes.Buffer
for _, line := range splitLines(s) {
buf.WriteString("// ")
buf.WriteString(line)
buf.WriteByte('\n')
}
return buf.String()
}
func splitLines(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, s[start:i])
start = i + 1
}
}
if start < len(s) {
out = append(out, s[start:])
}
return out
}
// hasVariants reports whether the variant list contains all of the named strings (order-insensitive).
func hasVariants(variants []string, names ...string) bool {
return matchesVariants(variants, names...)
}
// buildUnionTypeSet returns the set of all type names that generate interface types
// (i.e., types with one_of). This includes knownDiscriminators and marker-interface
// unions not covered by the discriminator map.
func buildUnionTypeSet(api *spec.API) map[string]bool {
s := make(map[string]bool, len(knownDiscriminators)+16)
for name := range knownDiscriminators {
s[name] = true
}
for _, t := range api.Types {
if len(t.OneOf) > 0 {
s[t.Name] = true
}
}
return s
}
// makeSentinelValue returns a sentinelValue func that uses the given union type set.
// It returns a minimal valid Go expression for a spec.Field's type,
// used in generated test param literals.
func makeSentinelValue(unionTypes map[string]bool) func(spec.Field) string {
return func(f spec.Field) string {
return sentinelForField(f, unionTypes)
}
}
func sentinelForField(f spec.Field, unionTypes map[string]bool) string {
tr := f.Type
switch tr.Kind {
case spec.KindPrimitive:
switch tr.Name {
case "int64":
return "42"
case "string":
return `"test_value"`
case "bool":
return "true"
case "float64":
return "1.0"
}
case spec.KindNamed:
switch tr.Name {
case "ChatID":
return "ChatIDFromInt(123)"
case "InputFile":
return `&InputFile{PathOrID: "file_id_test"}`
}
// Interface (union) types are nil-able.
if unionTypes[tr.Name] {
return "nil"
}
// Required named struct types are value types in the generated struct.
if f.Required {
return tr.Name + "{}"
}
return "&" + tr.Name + "{}"
case spec.KindArray:
return "nil"
case spec.KindOneOf:
if hasVariants(tr.Variants, "int64", "string") {
return "ChatIDFromInt(123)"
}
if hasVariants(tr.Variants, "InputFile", "string") {
return `&InputFile{PathOrID: "file_id_test"}`
}
// Sealed named-union interface: use nil (any).
return "nil"
}
return "nil"
}
// successResp returns a backtick Go string literal containing a minimal
// {"ok":true,"result":...} JSON body for the method's return type.
func successResp(m spec.MethodDecl) string {
body := successBody(m.Returns)
return "`{\"ok\":true,\"result\":" + body + "}`"
}
func successBody(tr spec.TypeRef) string {
switch tr.Kind {
case spec.KindPrimitive:
switch tr.Name {
case "bool":
return "true"
case "int64", "float64":
return "0"
case "string":
return `""`
}
case spec.KindNamed:
if tr.Name == "MessageOrBool" {
return "true"
}
// Sealed-interface unions need a discriminator field so UnmarshalXxx can dispatch.
// Pick the lexicographically first variant value for determinism (map
// iteration order in Go is randomized — using `range` directly produces
// non-deterministic regen output).
if disc, ok := knownDiscriminators[tr.Name]; ok && disc.Field != "" {
values := make([]string, 0, len(disc.Variants))
for v := range disc.Variants {
values = append(values, v)
}
sort.Strings(values)
if len(values) > 0 {
return fmt.Sprintf(`{"%s":"%s"}`, disc.Field, values[0])
}
}
// MaybeInaccessibleMessage uses date==0 → InaccessibleMessage variant.
if tr.Name == "MaybeInaccessibleMessage" {
return `{"date":0,"chat":{"id":1,"type":"private"},"message_id":1}`
}
return "{}"
case spec.KindArray:
return "[]"
case spec.KindOneOf:
return "null"
}
return "null"
}
// emitTests renders methods_gen_test.go.
func (e *emitter) emitTests() error {
unionTypes := buildUnionTypeSet(e.api)
// Add test-specific helpers to the shared func map.
fm := funcs()
fm["sentinelValue"] = makeSentinelValue(unionTypes)
fm["successResp"] = successResp
t, err := template.New("tests").Funcs(fm).Parse(testsTmpl)
if err != nil {
return fmt.Errorf("parse tests.tmpl: %w", err)
}
var buf bytes.Buffer
if execErr := t.Execute(&buf, e.api); execErr != nil {
return fmt.Errorf("execute tests.tmpl: %w", execErr)
}
src, err := format.Source(buf.Bytes())
if err != nil {
return fmt.Errorf("gofmt methods_gen_test.go: %w\n--- unformatted ---\n%s", err, buf.String())
}
return os.WriteFile(filepath.Join(e.outDir, "methods_gen_test.go"), src, 0o600)
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"flag"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
var updateGolden = flag.Bool("update", false, "update golden files")
func TestEmit_Types_FixtureGolden(t *testing.T) {
api, err := loadAPI("../../testdata/golden/api_small_fixture.json")
require.NoError(t, err)
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)
goldenPath := "../../testdata/golden/types.gen.go"
if *updateGolden {
require.NoError(t, os.WriteFile(goldenPath, got, 0o600))
return
}
expected, err := os.ReadFile(goldenPath)
require.NoError(t, err, "missing golden; run `go test -update ./cmd/genapi/...`")
require.Equal(t, string(expected), string(got))
}
func TestEmit_Enums_FixtureGolden(t *testing.T) {
api, err := loadAPI("../../testdata/golden/api_small_fixture.json")
require.NoError(t, err)
tmp := t.TempDir()
e := newEmitter(api, tmp)
require.NoError(t, e.emitEnums())
got, err := os.ReadFile(filepath.Join(tmp, "enums.gen.go"))
require.NoError(t, err)
goldenPath := "../../testdata/golden/enums.gen.go"
if *updateGolden {
require.NoError(t, os.WriteFile(goldenPath, got, 0o600))
return
}
expected, err := os.ReadFile(goldenPath)
require.NoError(t, err, "missing golden; run `go test -update ./cmd/genapi/...`")
require.Equal(t, string(expected), string(got))
}
func TestEmit_Methods_FixtureGolden(t *testing.T) {
api, err := loadAPI("../../testdata/golden/api_small_fixture.json")
require.NoError(t, err)
tmp := t.TempDir()
e := newEmitter(api, tmp)
require.NoError(t, e.emitTypes()) // some methods reference types
require.NoError(t, e.emitMethods())
got, err := os.ReadFile(filepath.Join(tmp, "methods.gen.go"))
require.NoError(t, err)
goldenPath := "../../testdata/golden/methods.gen.go"
if *updateGolden {
require.NoError(t, os.WriteFile(goldenPath, got, 0o600))
return
}
expected, err := os.ReadFile(goldenPath)
require.NoError(t, err, "missing golden; run `go test -update ./cmd/genapi/...`")
require.Equal(t, string(expected), string(got))
}
func TestEmit_Tests_FixtureGolden(t *testing.T) {
api, err := loadAPI("../../testdata/golden/api_small_fixture.json")
require.NoError(t, err)
tmp := t.TempDir()
e := newEmitter(api, tmp)
require.NoError(t, e.emitTests())
got, err := os.ReadFile(filepath.Join(tmp, "methods_gen_test.go"))
require.NoError(t, err)
goldenPath := "../../testdata/golden/methods_gen_test.go"
if *updateGolden {
require.NoError(t, os.WriteFile(goldenPath, got, 0o600))
return
}
expected, err := os.ReadFile(goldenPath)
require.NoError(t, err, "missing golden; run `go test -update ./cmd/genapi/...`")
require.Equal(t, string(expected), string(got))
}
+60
View File
@@ -0,0 +1,60 @@
// Code generated by cmd/genapi. DO NOT EDIT.
//go:build !ignore_autogenerated
package api
// ParseMode controls how Telegram interprets formatting in message text.
type ParseMode string
const (
ParseModeMarkdown ParseMode = "Markdown" // legacy
ParseModeMarkdownV2 ParseMode = "MarkdownV2"
ParseModeHTML ParseMode = "HTML"
)
// ChatType is the type of a Telegram chat.
type ChatType string
const (
ChatTypePrivate ChatType = "private"
ChatTypeGroup ChatType = "group"
ChatTypeSupergroup ChatType = "supergroup"
ChatTypeChannel ChatType = "channel"
)
// UpdateType identifies an Update payload variant. Used by allowed_updates
// in getUpdates / setWebhook.
type UpdateType string
const (
UpdateMessage UpdateType = "message"
UpdateEditedMessage UpdateType = "edited_message"
UpdateChannelPost UpdateType = "channel_post"
UpdateEditedChannelPost UpdateType = "edited_channel_post"
UpdateCallbackQuery UpdateType = "callback_query"
UpdateInlineQuery UpdateType = "inline_query"
)
// MessageEntityType is the kind of an entity (mention, hashtag, command, ...).
type MessageEntityType string
const (
EntityMention MessageEntityType = "mention"
EntityHashtag MessageEntityType = "hashtag"
EntityCashtag MessageEntityType = "cashtag"
EntityBotCommand MessageEntityType = "bot_command"
EntityURL MessageEntityType = "url"
EntityEmail MessageEntityType = "email"
EntityPhoneNumber MessageEntityType = "phone_number"
EntityBold MessageEntityType = "bold"
EntityItalic MessageEntityType = "italic"
EntityUnderline MessageEntityType = "underline"
EntityStrike MessageEntityType = "strikethrough"
EntitySpoiler MessageEntityType = "spoiler"
EntityCode MessageEntityType = "code"
EntityPre MessageEntityType = "pre"
EntityTextLink MessageEntityType = "text_link"
EntityTextMention MessageEntityType = "text_mention"
EntityCustomEmoji MessageEntityType = "custom_emoji"
)
+645
View File
@@ -0,0 +1,645 @@
package main
import (
"testing"
"github.com/lukaszraczylo/go-telegram/internal/spec"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// goType — all branches
// ---------------------------------------------------------------------------
func TestGoType_Primitive(t *testing.T) {
cases := []struct {
name string
optional bool
want string
}{
{"bool", false, "bool"},
{"bool", true, "*bool"},
{"int64", false, "int64"},
{"int64", true, "*int64"},
{"float64", false, "float64"},
{"float64", true, "*float64"},
{"string", false, "string"},
{"string", true, "string"}, // string is not pointer-wrapped
}
for _, c := range cases {
tr := spec.TypeRef{Kind: spec.KindPrimitive, Name: c.name}
got := goType(tr, c.optional)
require.Equal(t, c.want, got, "goType(%q, optional=%v)", c.name, c.optional)
}
}
func TestGoType_Named_Required(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}
require.Equal(t, "Message", goType(tr, false))
require.Equal(t, "*Message", goType(tr, true))
}
func TestGoType_Named_InputFile(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindNamed, Name: "InputFile"}
// InputFile is always pointer even when required.
require.Equal(t, "*InputFile", goType(tr, false))
require.Equal(t, "*InputFile", goType(tr, true))
}
func TestGoType_Named_UnionInterface(t *testing.T) {
// ChatMember is a known discriminated union — no * even when optional.
tr := spec.TypeRef{Kind: spec.KindNamed, Name: "ChatMember"}
require.Equal(t, "ChatMember", goType(tr, false))
require.Equal(t, "ChatMember", goType(tr, true))
}
func TestGoType_Array_NilElem(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindArray}
require.Equal(t, "[]any", goType(tr, false))
}
func TestGoType_Array_WithElem(t *testing.T) {
elem := spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}
tr := spec.TypeRef{Kind: spec.KindArray, ElemType: &elem}
require.Equal(t, "[]Update", goType(tr, false))
}
func TestGoType_OneOf_ChatID(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"int64", "string"}}
require.Equal(t, "ChatID", goType(tr, false))
require.Equal(t, "*ChatID", goType(tr, true))
}
func TestGoType_OneOf_InputFile(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"InputFile", "string"}}
require.Equal(t, "*InputFile", goType(tr, false))
}
func TestGoType_OneOf_SealedInterface(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"A", "B"}}
require.Equal(t, "any", goType(tr, false))
}
func TestGoType_Unknown(t *testing.T) {
tr := spec.TypeRef{Kind: spec.Kind(99)}
require.Equal(t, "any", goType(tr, false))
}
// ---------------------------------------------------------------------------
// returnGoType — all branches
// ---------------------------------------------------------------------------
func TestReturnGoType_Primitive(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}
require.Equal(t, "bool", returnGoType(tr))
}
func TestReturnGoType_Named(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}
require.Equal(t, "*Message", returnGoType(tr))
}
func TestReturnGoType_Array_NilElem(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindArray}
require.Equal(t, "[]any", returnGoType(tr))
}
func TestReturnGoType_Array_WithElem(t *testing.T) {
elem := spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}
tr := spec.TypeRef{Kind: spec.KindArray, ElemType: &elem}
require.Equal(t, "[]Update", returnGoType(tr))
}
func TestReturnGoType_OneOf_ChatID(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"int64", "string"}}
require.Equal(t, "ChatID", returnGoType(tr))
}
func TestReturnGoType_OneOf_Other(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"A", "B"}}
require.Equal(t, "any", returnGoType(tr))
}
func TestReturnGoType_Unknown(t *testing.T) {
tr := spec.TypeRef{Kind: spec.Kind(99)}
require.Equal(t, "any", returnGoType(tr))
}
// ---------------------------------------------------------------------------
// returnGoElem — all branches
// ---------------------------------------------------------------------------
func TestReturnGoElem_Primitive(t *testing.T) {
require.Equal(t, "int64", returnGoElem(spec.TypeRef{Kind: spec.KindPrimitive, Name: "int64"}))
}
func TestReturnGoElem_Named(t *testing.T) {
require.Equal(t, "Message", returnGoElem(spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}))
}
func TestReturnGoElem_Array_NilElem(t *testing.T) {
require.Equal(t, "any", returnGoElem(spec.TypeRef{Kind: spec.KindArray}))
}
func TestReturnGoElem_Array_WithElem(t *testing.T) {
elem := spec.TypeRef{Kind: spec.KindNamed, Name: "PhotoSize"}
tr := spec.TypeRef{Kind: spec.KindArray, ElemType: &elem}
require.Equal(t, "[]PhotoSize", returnGoElem(tr))
}
func TestReturnGoElem_Unknown(t *testing.T) {
require.Equal(t, "any", returnGoElem(spec.TypeRef{Kind: spec.Kind(99)}))
}
// ---------------------------------------------------------------------------
// multipartFieldEntry — all branches
// ---------------------------------------------------------------------------
func makeField(name, jname, typName string, kind spec.Kind, required bool) spec.Field {
return spec.Field{
Name: name,
JSONName: jname,
Type: spec.TypeRef{Kind: kind, Name: typName},
Required: required,
}
}
func makeFieldVariants(name, jname string, kind spec.Kind, variants []string, required bool) spec.Field {
return spec.Field{
Name: name,
JSONName: jname,
Type: spec.TypeRef{Kind: kind, Variants: variants},
Required: required,
}
}
func TestMultipartFieldEntry_Int64Required(t *testing.T) {
f := makeField("ChatID", "chat_id", "int64", spec.KindPrimitive, true)
got := multipartFieldEntry(f)
require.Contains(t, got, `FormatInt`)
require.NotContains(t, got, "if p.")
}
func TestMultipartFieldEntry_Int64Optional(t *testing.T) {
f := makeField("MessageThreadID", "message_thread_id", "int64", spec.KindPrimitive, false)
got := multipartFieldEntry(f)
require.Contains(t, got, `FormatInt`)
require.Contains(t, got, "if p.")
}
func TestMultipartFieldEntry_StringRequired(t *testing.T) {
f := makeField("Text", "text", "string", spec.KindPrimitive, true)
got := multipartFieldEntry(f)
require.Contains(t, got, `out["text"]`)
require.NotContains(t, got, "if p.Text")
}
func TestMultipartFieldEntry_StringOptional(t *testing.T) {
f := makeField("ParseMode", "parse_mode", "string", spec.KindPrimitive, false)
got := multipartFieldEntry(f)
require.Contains(t, got, `if p.ParseMode`)
}
func TestMultipartFieldEntry_BoolRequired(t *testing.T) {
f := makeField("DisableNotification", "disable_notification", "bool", spec.KindPrimitive, true)
got := multipartFieldEntry(f)
require.Contains(t, got, `FormatBool`)
require.NotContains(t, got, "if p.")
}
func TestMultipartFieldEntry_BoolOptional(t *testing.T) {
f := makeField("Protected", "protect_content", "bool", spec.KindPrimitive, false)
got := multipartFieldEntry(f)
require.Contains(t, got, `FormatBool`)
require.Contains(t, got, "if p.")
}
func TestMultipartFieldEntry_Float64Required(t *testing.T) {
f := makeField("Latitude", "latitude", "float64", spec.KindPrimitive, true)
got := multipartFieldEntry(f)
require.Contains(t, got, `FormatFloat`)
require.NotContains(t, got, "if p.")
}
func TestMultipartFieldEntry_Float64Optional(t *testing.T) {
f := makeField("Longitude", "longitude", "float64", spec.KindPrimitive, false)
got := multipartFieldEntry(f)
require.Contains(t, got, `FormatFloat`)
require.Contains(t, got, "if p.")
}
func TestMultipartFieldEntry_OneOf_ChatIDRequired(t *testing.T) {
f := makeFieldVariants("ChatID", "chat_id", spec.KindOneOf, []string{"int64", "string"}, true)
got := multipartFieldEntry(f)
require.Contains(t, got, `.String()`)
require.NotContains(t, got, "IsZero")
}
func TestMultipartFieldEntry_OneOf_ChatIDOptional(t *testing.T) {
f := makeFieldVariants("ChatID", "chat_id", spec.KindOneOf, []string{"int64", "string"}, false)
got := multipartFieldEntry(f)
require.Contains(t, got, `IsZero`)
}
func TestMultipartFieldEntry_OneOf_InputFileOrString(t *testing.T) {
f := makeFieldVariants("Photo", "photo", spec.KindOneOf, []string{"InputFile", "string"}, false)
got := multipartFieldEntry(f)
require.Contains(t, got, `PathOrID`)
}
func TestMultipartFieldEntry_OneOf_SealedRequired(t *testing.T) {
f := makeFieldVariants("Markup", "reply_markup", spec.KindOneOf, []string{"A", "B"}, true)
got := multipartFieldEntry(f)
require.Contains(t, got, `json.Marshal`)
}
func TestMultipartFieldEntry_OneOf_SealedOptional(t *testing.T) {
f := makeFieldVariants("Markup", "reply_markup", spec.KindOneOf, []string{"A", "B"}, false)
got := multipartFieldEntry(f)
require.Contains(t, got, `json.Marshal`)
require.Contains(t, got, "if p.Markup")
}
func TestMultipartFieldEntry_Named_Required(t *testing.T) {
f := makeField("Entities", "entities", "MessageEntity", spec.KindNamed, true)
got := multipartFieldEntry(f)
require.Contains(t, got, `json.Marshal`)
require.NotContains(t, got, "if p.")
}
func TestMultipartFieldEntry_Named_Optional(t *testing.T) {
f := makeField("Entities", "entities", "MessageEntity", spec.KindNamed, false)
got := multipartFieldEntry(f)
require.Contains(t, got, `json.Marshal`)
require.Contains(t, got, "if p.")
}
// ---------------------------------------------------------------------------
// unionTypeFor — all branches
// ---------------------------------------------------------------------------
func TestUnionTypeFor_DirectNamed(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindNamed, Name: "ChatMember"}
name, ok := unionTypeFor(tr)
require.True(t, ok)
require.Equal(t, "ChatMember", name)
}
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)
require.True(t, ok)
require.Equal(t, "ChatMember", name)
}
func TestUnionTypeFor_ArrayNilElem(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindArray}
_, ok := unionTypeFor(tr)
require.False(t, ok)
}
func TestUnionTypeFor_NotUnion(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}
_, ok := unionTypeFor(tr)
require.False(t, ok)
}
func TestUnionTypeFor_Unknown(t *testing.T) {
tr := spec.TypeRef{Kind: spec.Kind(99)}
_, ok := unionTypeFor(tr)
require.False(t, ok)
}
// ---------------------------------------------------------------------------
// unionNameByVariants
// ---------------------------------------------------------------------------
func TestUnionNameByVariants_ChatMember(t *testing.T) {
// Use the actual variants from knownDiscriminators["ChatMember"].
variants := []string{
"ChatMemberOwner", "ChatMemberAdministrator", "ChatMemberMember",
"ChatMemberRestricted", "ChatMemberLeft", "ChatMemberBanned",
}
name := unionNameByVariants(variants)
require.Equal(t, "ChatMember", name)
}
func TestUnionNameByVariants_Unknown(t *testing.T) {
name := unionNameByVariants([]string{"X", "Y", "Z"})
require.Equal(t, "", name)
}
// ---------------------------------------------------------------------------
// hasUnionElem
// ---------------------------------------------------------------------------
func TestHasUnionElem_NonArray(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindNamed, Name: "ChatMember"}
require.False(t, hasUnionElem(tr))
}
func TestHasUnionElem_ArrayNilElem(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindArray}
require.False(t, hasUnionElem(tr))
}
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))
}
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))
}
// ---------------------------------------------------------------------------
// unionFieldsOf
// ---------------------------------------------------------------------------
func TestUnionFieldsOf_WithUnionField(t *testing.T) {
td := spec.TypeDecl{
Name: "ChatMemberUpdated",
Fields: []spec.Field{
{Name: "NewChatMember", JSONName: "new_chat_member", Type: spec.TypeRef{Kind: spec.KindNamed, Name: "ChatMember"}},
{Name: "OldChatMember", JSONName: "old_chat_member", Type: spec.TypeRef{Kind: spec.KindNamed, Name: "ChatMember"}},
{Name: "Date", JSONName: "date", Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "int64"}},
},
}
uf := unionFieldsOf(td)
require.Len(t, uf, 2)
require.Equal(t, "ChatMember", uf[0].UnionName)
}
// ---------------------------------------------------------------------------
// splitLines — edge cases
// ---------------------------------------------------------------------------
func TestSplitLines_Empty(t *testing.T) {
require.Empty(t, splitLines(""))
}
func TestSplitLines_NoNewline(t *testing.T) {
got := splitLines("hello world")
require.Equal(t, []string{"hello world"}, got)
}
func TestSplitLines_TrailingNewline(t *testing.T) {
got := splitLines("line1\nline2\n")
require.Equal(t, []string{"line1", "line2"}, got)
}
func TestSplitLines_MultiLine(t *testing.T) {
got := splitLines("a\nb\nc")
require.Equal(t, []string{"a", "b", "c"}, got)
}
// ---------------------------------------------------------------------------
// docComment
// ---------------------------------------------------------------------------
func TestDocComment_Empty(t *testing.T) {
require.Equal(t, "", docComment(""))
}
func TestDocComment_SingleLine(t *testing.T) {
got := docComment("Hello world.")
require.Equal(t, "// Hello world.\n", got)
}
func TestDocComment_MultiLine(t *testing.T) {
got := docComment("Line 1\nLine 2")
require.Contains(t, got, "// Line 1\n")
require.Contains(t, got, "// Line 2\n")
}
// ---------------------------------------------------------------------------
// title
// ---------------------------------------------------------------------------
func TestTitle_Empty(t *testing.T) {
require.Equal(t, "", title(""))
}
func TestTitle_Lowercase(t *testing.T) {
require.Equal(t, "SendMessage", title("sendMessage"))
}
func TestTitle_AlreadyUpper(t *testing.T) {
require.Equal(t, "GetMe", title("GetMe"))
}
// ---------------------------------------------------------------------------
// mentionsInputFileTr
// ---------------------------------------------------------------------------
func TestMentionsInputFileTr_Named(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindNamed, Name: "InputFile"}
require.True(t, mentionsInputFileTr(tr))
}
func TestMentionsInputFileTr_NotInputFile(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}
require.False(t, mentionsInputFileTr(tr))
}
func TestMentionsInputFileTr_Array(t *testing.T) {
elem := spec.TypeRef{Kind: spec.KindNamed, Name: "InputFile"}
tr := spec.TypeRef{Kind: spec.KindArray, ElemType: &elem}
require.True(t, mentionsInputFileTr(tr))
}
func TestMentionsInputFileTr_ArrayNilElem(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindArray}
require.False(t, mentionsInputFileTr(tr))
}
func TestMentionsInputFileTr_OneOf_WithInputFile(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"InputFile", "string"}}
require.True(t, mentionsInputFileTr(tr))
}
func TestMentionsInputFileTr_OneOf_WithoutInputFile(t *testing.T) {
tr := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"A", "B"}}
require.False(t, mentionsInputFileTr(tr))
}
// ---------------------------------------------------------------------------
// loadAPI — error paths
// ---------------------------------------------------------------------------
func TestLoadAPI_MissingFile(t *testing.T) {
_, err := loadAPI("/nonexistent/path/api.json")
require.Error(t, err)
}
// ---------------------------------------------------------------------------
// runtimeTypes filter in emitTypes
// ---------------------------------------------------------------------------
func TestRuntimeTypes_NeverEmitted(t *testing.T) {
for name := range runtimeTypes {
require.True(t, runtimeTypes[name], "runtimeType %q should be true", name)
}
require.True(t, runtimeTypes["InputFile"])
require.True(t, runtimeTypes["ChatID"])
require.True(t, runtimeTypes["MessageOrBool"])
require.True(t, runtimeTypes["ResponseParameters"])
}
// ---------------------------------------------------------------------------
// sentinelForField — all branches
// ---------------------------------------------------------------------------
func TestSentinelForField(t *testing.T) {
unionTypes := map[string]bool{"ChatMember": true}
cases := []struct {
name string
field spec.Field
contains string
}{
{
name: "int64 primitive",
field: makeField("Count", "count", "int64", spec.KindPrimitive, true),
contains: "42",
},
{
name: "string primitive",
field: makeField("Text", "text", "string", spec.KindPrimitive, true),
contains: "test_value",
},
{
name: "bool primitive",
field: makeField("Flag", "flag", "bool", spec.KindPrimitive, true),
contains: "true",
},
{
name: "float64 primitive",
field: makeField("Lat", "lat", "float64", spec.KindPrimitive, true),
contains: "1.0",
},
{
name: "named ChatID",
field: makeField("ChatID", "chat_id", "ChatID", spec.KindNamed, true),
contains: "ChatIDFromInt",
},
{
name: "named InputFile",
field: makeField("Photo", "photo", "InputFile", spec.KindNamed, true),
contains: "InputFile",
},
{
name: "named union (nil-able)",
field: makeField("Member", "member", "ChatMember", spec.KindNamed, true),
contains: "nil",
},
{
name: "named required struct",
field: makeField("Chat", "chat", "Chat", spec.KindNamed, true),
contains: "Chat{}",
},
{
name: "named optional struct",
field: makeField("Chat", "chat", "Chat", spec.KindNamed, false),
contains: "&Chat{}",
},
{
name: "array",
field: spec.Field{Name: "Items", JSONName: "items", Type: spec.TypeRef{Kind: spec.KindArray}},
contains: "nil",
},
{
name: "oneOf ChatID variants",
field: makeFieldVariants("ChatID", "chat_id", spec.KindOneOf, []string{"int64", "string"}, true),
contains: "ChatIDFromInt",
},
{
name: "oneOf InputFile variants",
field: makeFieldVariants("Photo", "photo", spec.KindOneOf, []string{"InputFile", "string"}, true),
contains: "InputFile",
},
{
name: "oneOf sealed",
field: makeFieldVariants("Markup", "markup", spec.KindOneOf, []string{"A", "B"}, true),
contains: "nil",
},
{
name: "unknown kind",
field: spec.Field{Name: "X", JSONName: "x", Type: spec.TypeRef{Kind: spec.Kind(99)}},
contains: "nil",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := sentinelForField(c.field, unionTypes)
require.Contains(t, got, c.contains, "sentinelForField for %q", c.name)
})
}
}
// ---------------------------------------------------------------------------
// successBody — all branches
// ---------------------------------------------------------------------------
func TestSuccessBody(t *testing.T) {
cases := []struct {
name string
tr spec.TypeRef
want string
}{
{"bool", spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}, "true"},
{"int64", spec.TypeRef{Kind: spec.KindPrimitive, Name: "int64"}, "0"},
{"float64", spec.TypeRef{Kind: spec.KindPrimitive, Name: "float64"}, "0"},
{"string", spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}, `""`},
{"MessageOrBool", spec.TypeRef{Kind: spec.KindNamed, Name: "MessageOrBool"}, "true"},
{"named", spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}, "{}"},
{"array", spec.TypeRef{Kind: spec.KindArray}, "[]"},
{"oneOf", spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"A", "B"}}, "null"},
{"unknown", spec.TypeRef{Kind: spec.Kind(99)}, "null"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := successBody(c.tr)
require.Equal(t, c.want, got)
})
}
}
// ---------------------------------------------------------------------------
// unionTypeFor — KindOneOf branch (variant-set match)
// ---------------------------------------------------------------------------
func TestUnionTypeFor_OneOfVariants(t *testing.T) {
// These variant *type names* match the ChatMember discriminator.
tr := spec.TypeRef{
Kind: spec.KindOneOf,
Variants: []string{
"ChatMemberOwner", "ChatMemberAdministrator", "ChatMemberMember",
"ChatMemberRestricted", "ChatMemberLeft", "ChatMemberBanned",
},
}
name, ok := unionTypeFor(tr)
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)
require.False(t, ok)
}
// ---------------------------------------------------------------------------
// funcs() returns a non-nil FuncMap with expected keys
// ---------------------------------------------------------------------------
func TestFuncs_HasExpectedKeys(t *testing.T) {
fm := funcs()
require.NotNil(t, fm)
for _, key := range []string{"goType", "docComment", "returnGoType", "unionFields"} {
require.NotNil(t, fm[key], "funcs() missing key %q", key)
}
}
+49
View File
@@ -0,0 +1,49 @@
// Command genapi reads internal/spec/api.json and emits api/*.gen.go.
//
// Usage:
//
// genapi -input <file> (default: internal/spec/api.json)
// genapi -outdir <dir> (default: api)
package main
import (
"flag"
"fmt"
"os"
)
func main() {
input := flag.String("input", "internal/spec/api.json", "IR JSON path")
outdir := flag.String("outdir", "api", "output directory")
flag.Parse()
if err := run(*input, *outdir); err != nil {
fmt.Fprintln(os.Stderr, "genapi:", err)
os.Exit(1)
}
}
// run is filled in by P2.T8/T9/T10.
func run(input, outdir string) error {
api, err := loadAPI(input)
if err != nil {
return fmt.Errorf("load api: %w", err)
}
if err := os.MkdirAll(outdir, 0o750); err != nil {
return err
}
e := newEmitter(api, outdir)
if err := e.emitTypes(); err != nil {
return err
}
if err := e.emitMethods(); err != nil {
return err
}
if err := e.emitEnums(); err != nil {
return err
}
if err := e.emitTests(); err != nil {
return err
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
// Code generated by cmd/genapi. DO NOT EDIT.
//go:build !ignore_autogenerated
package api
import (
"context"
"github.com/goccy/go-json"
"strconv"
"github.com/lukaszraczylo/go-telegram/client"
)
var _ = strconv.Itoa // keep import for multipart helpers
var _ = json.Marshal // keep import for complex multipart fields
{{range .Methods}}
// {{title .Name}}Params is the parameter set for {{title .Name}}.
//
{{docComment .Doc -}}
type {{title .Name}}Params struct {
{{range .Params}}{{docComment .Doc}} {{goField .}}
{{end}}}
{{if .HasFiles}}
// HasFile reports whether a multipart upload is required.
func (p *{{title .Name}}Params) HasFile() bool {
{{range .Params}}{{if isFileField .}}{{fileCheck .}}{{end}}{{end}} return false
}
// MultipartFields returns the non-file fields used in the multipart body.
func (p *{{title .Name}}Params) MultipartFields() map[string]string {
out := map[string]string{}
{{range .Params}}{{if not (isFileField .)}}{{multipartFieldEntry .}}{{end}}{{end}} return out
}
// MultipartFiles returns the file parts.
func (p *{{title .Name}}Params) MultipartFiles() []client.MultipartFile {
var files []client.MultipartFile
{{range .Params}}{{if isFileField .}}{{multipartFileEntry .}}{{end}}{{end}} return files
}
{{end}}
// {{title .Name}} calls the {{.Name}} Telegram Bot API method.
//
{{docComment .Doc -}}
func {{title .Name}}(ctx context.Context, b *client.Bot, p *{{title .Name}}Params) ({{returnGoType .Returns}}, error) {
{{if isSealedUnionReturn .Returns -}}
raw, err := client.CallRaw[*{{title .Name}}Params](ctx, b, "{{.Name}}", p)
if err != nil {
return nil, err
}
return Unmarshal{{.Returns.Name}}(raw)
{{else -}}
return client.Call[*{{title .Name}}Params, {{returnGoType .Returns}}](ctx, b, "{{.Name}}", p)
{{end -}}
}
{{end}}
+220
View File
@@ -0,0 +1,220 @@
// Code generated by cmd/genapi. DO NOT EDIT.
//go:build !ignore_autogenerated
package api
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// genTestMockDoer is a testify-mock HTTPDoer used by generated tests only.
type genTestMockDoer struct{ mock.Mock }
func (m *genTestMockDoer) Do(r *http.Request) (*http.Response, error) {
args := m.Called(r)
if v := args.Get(0); v != nil {
return v.(*http.Response), args.Error(1)
}
return nil, args.Error(1)
}
func genTestResp(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
}
{{range .Methods}}{{$m := .}}{{$mName := title .Name}}{{$mWire := .Name}}
func Test_{{$mName}}_Success(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return strings.HasSuffix(r.URL.Path, "/{{$mWire}}")
})).Return(genTestResp(200, {{successResp $m}}), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
{{- else}}
_, err := {{$mName}}(context.Background(), bot, &{{$mName}}Params{})
{{- end}}
require.NoError(t, err)
}
func Test_{{$mName}}_APIError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":1}}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
{{- else}}
_, err := {{$mName}}(context.Background(), bot, &{{$mName}}Params{})
{{- end}}
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 429, ae.Code)
require.True(t, ae.IsRetryable())
}
func Test_{{$mName}}_NetworkError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(nil, errors.New("dial tcp: timeout"))
bot := client.New("test:token", client.WithHTTPClient(m))
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
{{- else}}
_, err := {{$mName}}(context.Background(), bot, &{{$mName}}Params{})
{{- end}}
require.Error(t, err)
var ne *client.NetworkError
require.ErrorAs(t, err, &ne)
}
func Test_{{$mName}}_ParseError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(genTestResp(200, `not json`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
{{- else}}
_, err := {{$mName}}(context.Background(), bot, &{{$mName}}Params{})
{{- end}}
require.Error(t, err)
var pe *client.ParseError
require.ErrorAs(t, err, &pe)
}
func Test_{{$mName}}_ContextCanceled(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(nil, context.Canceled).Maybe()
ctx, cancel := context.WithCancel(context.Background())
cancel()
bot := client.New("test:token", client.WithHTTPClient(m))
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(ctx, bot, params)
{{- else}}
_, err := {{$mName}}(ctx, bot, &{{$mName}}Params{})
{{- end}}
require.Error(t, err)
require.ErrorIs(t, err, context.Canceled)
}
// Test_{{$mName}}_MissingRequiredFields exercises Telegram's server-side
// validation: when a required field is omitted, Telegram returns 400 with
// a description like "Bad Request: <field> is empty". The library must
// surface this as *APIError with the ErrBadRequest sentinel.
func Test_{{$mName}}_MissingRequiredFields(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":400,"description":"Bad Request: chat_id is empty"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
// Send a Params with all required fields zeroed — simulates a caller
// that forgot to populate them. The bot library marshals as-is and
// surfaces Telegram's 400 reply.
_, err := {{$mName}}(context.Background(), bot, &{{$mName}}Params{})
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 400, ae.Code)
require.True(t, errors.Is(err, client.ErrBadRequest))
require.False(t, ae.IsRetryable())
}
// Test_{{$mName}}_Forbidden exercises the 403 path (bot blocked by user,
// removed from chat, etc.). The library must surface the ErrForbidden
// sentinel.
func Test_{{$mName}}_Forbidden(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
{{- else}}
_, err := {{$mName}}(context.Background(), bot, &{{$mName}}Params{})
{{- end}}
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 403, ae.Code)
require.True(t, errors.Is(err, client.ErrForbidden))
require.False(t, ae.IsRetryable())
}
// Test_{{$mName}}_ServerError exercises the 5xx path. The library must
// classify these as retryable so RetryDoer / user retry logic kicks in.
func Test_{{$mName}}_ServerError(t *testing.T) {
m := &genTestMockDoer{}
m.On("Do", mock.Anything).Return(
genTestResp(200, `{"ok":false,"error_code":500,"description":"Internal server error"}`), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
{{- else}}
_, err := {{$mName}}(context.Background(), bot, &{{$mName}}Params{})
{{- end}}
require.Error(t, err)
var ae *client.APIError
require.ErrorAs(t, err, &ae)
require.Equal(t, 500, ae.Code)
require.True(t, ae.IsRetryable(), "5xx must be retryable")
}
{{end}}
+126
View File
@@ -0,0 +1,126 @@
// Code generated by cmd/genapi. DO NOT EDIT.
//go:build !ignore_autogenerated
// Package api contains the Telegram Bot API object types and method
// wrappers, generated from the live documentation by cmd/genapi.
package api
import (
"github.com/goccy/go-json"
"fmt"
"io"
)
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
{{range .Types}}
{{- $td := . -}}
{{if .OneOf}}
// {{.Name}} is a union type. The following concrete variants implement
// it:
{{range .OneOf}}// - {{.}}
{{end}}//
{{docComment .Doc -}}
type {{.Name}} interface{ is{{.Name}}() }
{{range .OneOf}}
// is{{$td.Name}} is the marker method that makes {{.}} implement {{$td.Name}}.
func (*{{.}}) is{{$td.Name}}() {}
{{end}}
{{if hasDiscriminator .Name}}
// 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 {
V string `json:"{{discriminatorField .Name}}"`
}
if err := json.Unmarshal(data, &probe); err != nil {
return nil, err
}
var v {{.Name}}
switch probe.V {
{{range $val, $typ := discriminatorMap .Name}} case {{printf "%q" $val}}:
v = &{{$typ}}{}
{{end}} default:
return nil, fmt.Errorf("{{.Name}}: unknown {{discriminatorField .Name}} %q", probe.V)
}
if err := json.Unmarshal(data, v); err != nil {
return nil, err
}
return v, nil
}
{{end}}
{{if isMaybeInaccessibleMessage .Name}}
// UnmarshalMaybeInaccessibleMessage decodes a JSON object into the correct
// MaybeInaccessibleMessage variant. Telegram uses the date field as a
// discriminator: date == 0 indicates InaccessibleMessage; any other value
// indicates a real Message.
func UnmarshalMaybeInaccessibleMessage(data []byte) (MaybeInaccessibleMessage, error) {
var probe struct {
Date int64 `json:"date"`
}
if err := json.Unmarshal(data, &probe); err != nil {
return nil, fmt.Errorf("MaybeInaccessibleMessage: %w", err)
}
if probe.Date == 0 {
v := &InaccessibleMessage{}
if err := json.Unmarshal(data, v); err != nil {
return nil, fmt.Errorf("InaccessibleMessage: %w", err)
}
return v, nil
}
v := &Message{}
if err := json.Unmarshal(data, v); err != nil {
return nil, fmt.Errorf("Message: %w", err)
}
return v, nil
}
{{end}}
{{else}}
{{docComment .Doc -}}
type {{.Name}} struct {
{{range .Fields}}{{docComment .Doc}}{{goField .}}
{{end}}}
{{$unionFields := unionFields .}}{{if $unionFields}}
// UnmarshalJSON decodes {{.Name}} by dispatching union-typed fields
// ({{range $i, $u := $unionFields}}{{if $i}}, {{end}}{{$u.Field.Name}}{{end}}) through their concrete UnmarshalXxx helpers.
func (m *{{.Name}}) UnmarshalJSON(data []byte) error {
type Alias {{.Name}}
aux := &struct {
{{range $unionFields}}{{.Field.Name}} json.RawMessage `json:"{{.Field.JSONName}},omitempty"`
{{end}}*Alias
}{Alias: (*Alias)(m)}
if err := json.Unmarshal(data, aux); err != nil {
return err
}
{{range $unionFields}}{{$f := .Field}}{{$u := .UnionName}}
if len(aux.{{$f.Name}}) > 0 && string(aux.{{$f.Name}}) != "null" {
{{if isArrayUnion $f.Type}}var raws []json.RawMessage
if err := json.Unmarshal(aux.{{$f.Name}}, &raws); err != nil {
return fmt.Errorf("decoding {{$f.JSONName}}: %w", err)
}
decoded := make([]{{$u}}, 0, len(raws))
for i, r := range raws {
v, err := Unmarshal{{$u}}(r)
if err != nil {
return fmt.Errorf("decoding {{$f.JSONName}}[%d]: %w", i, err)
}
decoded = append(decoded, v)
}
m.{{$f.Name}} = decoded
{{else}}v, err := Unmarshal{{$u}}(aux.{{$f.Name}})
if err != nil {
return fmt.Errorf("decoding {{$f.JSONName}}: %w", err)
}
m.{{$f.Name}} = v
{{end}}
}
{{end}}
return nil
}
{{end}}
{{end}}
{{end}}