mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-09 23:04:05 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user