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 9072e9eafb
167 changed files with 106860 additions and 0 deletions
+378
View File
@@ -0,0 +1,378 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"github.com/lukaszraczylo/go-telegram/internal/spec"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// loadIR
// ---------------------------------------------------------------------------
func TestLoadIR_ValidFile(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
},
}
data, err := json.Marshal(api)
require.NoError(t, err)
tmp := filepath.Join(t.TempDir(), "api.json")
require.NoError(t, os.WriteFile(tmp, data, 0o600))
loaded, err := loadIR(tmp)
require.NoError(t, err)
require.Len(t, loaded.Methods, 1)
require.Equal(t, "getMe", loaded.Methods[0].Name)
}
func TestLoadIR_MissingFile(t *testing.T) {
_, err := loadIR("/nonexistent/path/api.json")
require.Error(t, err)
require.Contains(t, err.Error(), "open IR")
}
func TestLoadIR_InvalidJSON(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "bad.json")
require.NoError(t, os.WriteFile(tmp, []byte("not json"), 0o600))
_, err := loadIR(tmp)
require.Error(t, err)
require.Contains(t, err.Error(), "decode IR")
}
// ---------------------------------------------------------------------------
// auditBool
// ---------------------------------------------------------------------------
func TestAuditBool_LongDocTruncated(t *testing.T) {
longDoc := make([]byte, 200)
for i := range longDoc {
longDoc[i] = 'a'
}
api := &spec.API{
Methods: []spec.MethodDecl{
{Name: "myMethod", Doc: string(longDoc), Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
},
}
problems := auditBool(api, &spec.Overrides{})
require.Len(t, problems, 1)
require.Contains(t, problems[0], "…")
}
func TestAuditBool_TrueIsReturnedVariant(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{Name: "doThing", Doc: "true is returned on success.", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
},
}
require.Empty(t, auditBool(api, &spec.Overrides{}))
}
func TestAuditBool_ReturnsBoolean(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{Name: "doThing", Doc: "Returns Boolean on success.", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
},
}
require.Empty(t, auditBool(api, &spec.Overrides{}))
}
// ---------------------------------------------------------------------------
// formatTypeRef
// ---------------------------------------------------------------------------
func TestFormatTypeRef_AllBranches(t *testing.T) {
cases := []struct {
tr spec.TypeRef
want string
}{
{spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}, "bool"},
{spec.TypeRef{Kind: spec.KindNamed, Name: "User"}, "User"},
{spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}}, "[]Update"},
{spec.TypeRef{Kind: spec.KindArray}, "[]any"},
{spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"int64", "string"}}, "(int64 | string)"},
{spec.TypeRef{Kind: spec.Kind(99)}, "?"},
}
for _, c := range cases {
got := formatTypeRef(c.tr)
require.Equal(t, c.want, got, "for kind=%v name=%v", c.tr.Kind, c.tr.Name)
}
}
// ---------------------------------------------------------------------------
// auditDrift
// ---------------------------------------------------------------------------
func TestAuditDrift_InvalidRefReturnsError(t *testing.T) {
cur := &spec.API{
Methods: []spec.MethodDecl{
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
},
}
_, err := auditDrift("internal/spec/api.json", "THIS_REF_DOES_NOT_EXIST", cur)
require.Error(t, err)
}
func TestAuditDrift_SameRefNoDrift(t *testing.T) {
irPath := "../../internal/spec/api.json"
cur, err := loadIR(irPath)
if err != nil {
t.Skip("api.json not available, skipping drift test")
}
changes, err := auditDrift(irPath, "HEAD", cur)
require.NoError(t, err)
require.Empty(t, changes)
}
func TestAuditDrift_InvalidJSONFromGit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell script not supported on Windows")
}
tmp := t.TempDir()
fakeGit := filepath.Join(tmp, "git")
require.NoError(t, os.WriteFile(fakeGit, []byte("#!/bin/sh\necho 'not valid json'\n"), 0o600))
require.NoError(t, os.Chmod(fakeGit, 0o755))
origPATH := os.Getenv("PATH")
t.Cleanup(func() { _ = os.Setenv("PATH", origPATH) })
_ = os.Setenv("PATH", tmp+string(os.PathListSeparator)+origPATH)
_, err := auditDrift("internal/spec/api.json", "HEAD", &spec.API{})
require.Error(t, err)
require.Contains(t, err.Error(), "decode")
}
// ---------------------------------------------------------------------------
// auditAny
// ---------------------------------------------------------------------------
func TestAuditAny_FlagsUnknownMethodReturn(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{
Name: "weirdMethod",
Returns: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"A", "B", "C"}},
},
},
}
out := auditAny(api)
require.Len(t, out, 1)
require.Contains(t, out[0], "any return: weirdMethod")
}
func TestAuditAny_FlagsUnknownMethodParam(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{
Name: "weirdMethod",
Params: []spec.Field{
{Name: "Thing", JSONName: "thing", Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"X", "Y", "Z"}}},
},
},
},
}
out := auditAny(api)
require.Len(t, out, 1)
require.Contains(t, out[0], "any param: weirdMethod.Thing")
}
// ---------------------------------------------------------------------------
// diffSignatures
// ---------------------------------------------------------------------------
func TestDiffSignatures_UnchangedNoDrift(t *testing.T) {
prev := &spec.API{
Methods: []spec.MethodDecl{
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
},
}
cur := &spec.API{
Methods: []spec.MethodDecl{
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
},
}
require.Empty(t, diffSignatures(prev, cur))
}
// ---------------------------------------------------------------------------
// typeRefEqual
// ---------------------------------------------------------------------------
func TestTypeRefEqual_ArrayNilElemDiffers(t *testing.T) {
a := spec.TypeRef{Kind: spec.KindArray}
b := spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}}
require.False(t, typeRefEqual(a, b))
require.False(t, typeRefEqual(b, a))
}
// ---------------------------------------------------------------------------
// TestHelperMain: subprocess helper for main() coverage.
//
// When AUDIT_HELPER_MAIN=1 is set, this function:
// 1. Resets flag.CommandLine so main()'s flag.Parse() gets a clean slate.
// 2. Sets os.Args to the args encoded in AUDIT_HELPER_ARGS.
// 3. Calls main() which calls os.Exit — the test process terminates with
// main's exit code, which the parent test captures.
// ---------------------------------------------------------------------------
func TestHelperMain(t *testing.T) {
if os.Getenv("AUDIT_HELPER_MAIN") != "1" {
t.Skip("not running as subprocess helper")
}
// Reset flag.CommandLine so main()'s flag.Parse() gets a clean slate.
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
// Decode args from JSON-encoded env var.
encoded := os.Getenv("AUDIT_HELPER_ARGS")
if encoded != "" {
var args []string
if err := json.Unmarshal([]byte(encoded), &args); err == nil {
os.Args = append([]string{os.Args[0]}, args...)
}
} else {
os.Args = os.Args[:1]
}
main()
}
// runMain runs main() via the test binary subprocess so that coverage counters
// from main() are included in the profile. Args are JSON-encoded in an env var
// to avoid conflicts with the test binary's own flag parsing.
func runMain(t *testing.T, extraEnv []string, args ...string) (string, int) {
t.Helper()
argsJSON, _ := json.Marshal(args)
cmd := exec.Command(os.Args[0], "-test.run=TestHelperMain", "-test.v=false")
cmd.Env = append(os.Environ(), "AUDIT_HELPER_MAIN=1", "AUDIT_HELPER_ARGS="+string(argsJSON))
cmd.Env = append(cmd.Env, extraEnv...)
out, err := cmd.CombinedOutput()
code := 0
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
}
}
return string(out), code
}
// ---------------------------------------------------------------------------
// main() integration tests — exercise main() code paths via subprocess
// ---------------------------------------------------------------------------
func TestMain_CleanExitsZero(t *testing.T) {
tmp := t.TempDir()
ir := writeIR(t, tmp, &spec.API{
Methods: []spec.MethodDecl{
{Name: "getMe", Doc: "Returns True on success.", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
},
})
ov := writeOverrides(t, tmp)
out, code := runMain(t, nil, "-ir", ir, "-overrides", ov)
require.Equal(t, exitClean, code, "expected exit 0 (clean)\nout: %s", out)
require.Contains(t, out, "clean")
}
func TestMain_FallbackExitsOne(t *testing.T) {
tmp := t.TempDir()
ir := writeIR(t, tmp, &spec.API{
Methods: []spec.MethodDecl{
{Name: "doSomething", Doc: "Does something.", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
},
})
ov := writeOverrides(t, tmp)
out, code := runMain(t, nil, "-ir", ir, "-overrides", ov)
require.Equal(t, exitFallback, code, "expected exit 1 (fallback)\nout: %s", out)
require.Contains(t, out, "bool fallback")
}
func TestMain_InvalidIRExitsThree(t *testing.T) {
tmp := t.TempDir()
bad := filepath.Join(tmp, "bad.json")
require.NoError(t, os.WriteFile(bad, []byte("not json"), 0o600))
ov := writeOverrides(t, tmp)
out, code := runMain(t, nil, "-ir", bad, "-overrides", ov)
require.Equal(t, exitInvalid, code, "expected exit 3 (invalid IR)\nout: %s", out)
}
func TestMain_InvalidOverridesExitsThree(t *testing.T) {
tmp := t.TempDir()
ir := writeIR(t, tmp, &spec.API{})
bad := filepath.Join(tmp, "bad_ov.json")
require.NoError(t, os.WriteFile(bad, []byte("not json"), 0o600))
out, code := runMain(t, nil, "-ir", ir, "-overrides", bad)
require.Equal(t, exitInvalid, code, "expected exit 3 (invalid overrides)\nout: %s", out)
}
func TestMain_DriftDetectedExitsTwo(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell script not supported on Windows")
}
tmp := t.TempDir()
prevAPI := &spec.API{
Methods: []spec.MethodDecl{
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
},
}
curAPI := &spec.API{
Methods: []spec.MethodDecl{
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}},
},
}
curIR := writeIR(t, tmp, curAPI)
ov := writeOverrides(t, tmp)
prevData, err := json.Marshal(prevAPI)
require.NoError(t, err)
prevFile := filepath.Join(tmp, "prev.json")
require.NoError(t, os.WriteFile(prevFile, prevData, 0o600))
fakeGit := filepath.Join(tmp, "git")
script := fmt.Sprintf("#!/bin/sh\ncat %s\n", prevFile)
require.NoError(t, os.WriteFile(fakeGit, []byte(script), 0o600))
require.NoError(t, os.Chmod(fakeGit, 0o755))
newPATH := tmp + string(os.PathListSeparator) + os.Getenv("PATH")
out, code := runMain(t,
[]string{"PATH=" + newPATH},
"-ir", curIR, "-overrides", ov, "-drift", "-against", "HEAD~1",
)
require.Equal(t, exitDrift, code, "expected exit 2 (drift)\nout: %s", out)
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
func writeIR(t *testing.T, dir string, api *spec.API) string {
t.Helper()
data, err := json.Marshal(api)
require.NoError(t, err)
p := filepath.Join(dir, "api.json")
require.NoError(t, os.WriteFile(p, data, 0o600))
return p
}
func writeOverrides(t *testing.T, dir string) string {
t.Helper()
p := filepath.Join(dir, "overrides.json")
require.NoError(t, os.WriteFile(p, []byte("{}"), 0o600))
return p
}
+291
View File
@@ -0,0 +1,291 @@
// Command audit reports IR-level codegen fallbacks and signature drift.
//
// Usage:
//
// audit -ir <path> (default internal/spec/api.json)
// audit -overrides <path> (default internal/spec/overrides.json)
// audit -drift (compare against -against ref's IR; off by default)
// audit -against <ref> (git ref to diff drift against; default HEAD~1)
//
// Exit codes:
//
// 0 — clean
// 1 — unaccounted bool fallbacks or any-typed fields
// 2 — drift detected (signature changed)
// 3 — invalid IR or overrides
package main
import (
"flag"
"fmt"
"github.com/goccy/go-json"
"os"
"os/exec"
"strings"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
const (
exitClean = 0
exitFallback = 1
exitDrift = 2
exitInvalid = 3
)
func main() {
irPath := flag.String("ir", "internal/spec/api.json", "path to IR JSON")
ovPath := flag.String("overrides", "internal/spec/overrides.json", "path to overrides JSON")
checkDrift := flag.Bool("drift", false, "compare against -against ref's IR for signature changes")
againstRef := flag.String("against", "HEAD~1", "git ref to diff drift against (e.g. origin/main, HEAD~1)")
flag.Parse()
api, err := loadIR(*irPath)
if err != nil {
fmt.Fprintln(os.Stderr, "audit:", err)
os.Exit(exitInvalid)
}
overrides, err := spec.LoadOverrides(*ovPath)
if err != nil {
fmt.Fprintln(os.Stderr, "audit:", err)
os.Exit(exitInvalid)
}
var problems []string
problems = append(problems, auditBool(api, overrides)...)
problems = append(problems, auditAny(api)...)
driftFound := false
if *checkDrift {
if d, err := auditDrift(*irPath, *againstRef, api); err != nil {
fmt.Fprintln(os.Stderr, "audit: drift check skipped:", err)
} else if len(d) > 0 {
fmt.Println("Drift detected (signatures changed since HEAD):")
for _, p := range d {
fmt.Println(" ", p)
}
driftFound = true
}
}
if len(problems) == 0 && !driftFound {
fmt.Println("audit: clean")
os.Exit(exitClean)
}
if len(problems) > 0 {
fmt.Println("Codegen fallbacks requiring action:")
for _, p := range problems {
fmt.Println(" ", p)
}
fmt.Println()
fmt.Println("To resolve: extend cmd/scrape/method.go regex patterns OR")
fmt.Println("add an entry to internal/spec/overrides.json.")
os.Exit(exitFallback)
}
// drift only, no fallbacks
os.Exit(exitDrift)
}
func loadIR(path string) (*spec.API, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open IR: %w", err)
}
defer func() { _ = f.Close() }()
var api spec.API
if err := json.NewDecoder(f).Decode(&api); err != nil {
return nil, fmt.Errorf("decode IR: %w", err)
}
return &api, nil
}
// auditBool returns problems for methods returning bool whose docs don't
// actually say "Returns True" / etc. and which aren't in the approved list.
func auditBool(api *spec.API, ov *spec.Overrides) []string {
var out []string
for _, m := range api.Methods {
if m.Returns.Kind != spec.KindPrimitive || m.Returns.Name != "bool" {
continue
}
if ov.IsBoolApproved(m.Name) {
continue
}
if looksGenuinelyBool(m.Doc) {
continue
}
snippet := m.Doc
if len(snippet) > 120 {
snippet = snippet[:120] + "…"
}
out = append(out, fmt.Sprintf("bool fallback: %s — doc: %q", m.Name, snippet))
}
return out
}
func looksGenuinelyBool(doc string) bool {
for _, p := range []string{
"Returns True", "Returns true",
"True is returned", "true is returned",
"Returns Boolean", "Returns Bool",
} {
if strings.Contains(doc, p) {
return true
}
}
return false
}
// auditAny scans the IR for any KindOneOf TypeRef that would render as
// `any` in generated code (not matched by ChatID/InputFile-or-string/known
// union heuristics). Reports each occurrence with location.
func auditAny(api *spec.API) []string {
var out []string
isKnownUnion := func(variants []string) bool {
if hasVariants(variants, "int64", "string") {
return true // ChatID
}
if hasVariants(variants, "InputFile", "string") {
return true // *InputFile
}
// ReplyMarkup union: all four keyboard types — emitter renders as `any` intentionally
if hasVariants(variants, "InlineKeyboardMarkup", "ReplyKeyboardMarkup", "ReplyKeyboardRemove", "ForceReply") {
return true
}
for _, t := range api.Types {
if len(t.OneOf) > 0 && sameSet(variants, t.OneOf) {
return true
}
}
return false
}
isAny := func(tr spec.TypeRef) bool {
return tr.Kind == spec.KindOneOf && !isKnownUnion(tr.Variants)
}
for _, t := range api.Types {
for _, f := range t.Fields {
if isAny(f.Type) {
out = append(out, fmt.Sprintf("any field: %s.%s (variants=%v)", t.Name, f.Name, f.Type.Variants))
}
}
}
for _, m := range api.Methods {
if isAny(m.Returns) {
out = append(out, fmt.Sprintf("any return: %s (variants=%v)", m.Name, m.Returns.Variants))
}
for _, p := range m.Params {
if isAny(p.Type) {
out = append(out, fmt.Sprintf("any param: %s.%s (variants=%v)", m.Name, p.Name, p.Type.Variants))
}
}
}
return out
}
func hasVariants(got []string, want ...string) bool {
if len(got) != len(want) {
return false
}
seen := map[string]int{}
for _, g := range got {
seen[g]++
}
for _, w := range want {
seen[w]--
}
for _, v := range seen {
if v != 0 {
return false
}
}
return true
}
func sameSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
return hasVariants(a, b...)
}
// auditDrift compares method/return signatures between the given git ref's
// version of irPath and the in-memory current IR. Returns a list of
// human-readable change descriptions.
func auditDrift(irPath, againstRef string, current *spec.API) ([]string, error) {
cmd := exec.Command("git", "show", againstRef+":"+irPath) // #nosec G204 - operator tool, ref controlled by caller
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git show %s: %w", againstRef, err)
}
var prev spec.API
if err := json.Unmarshal(out, &prev); err != nil {
return nil, fmt.Errorf("decode %s IR: %w", againstRef, err)
}
return diffSignatures(&prev, current), nil
}
func diffSignatures(prev, cur *spec.API) []string {
var changes []string
pmeth := indexByName(prev.Methods, func(m spec.MethodDecl) string { return m.Name })
cmeth := indexByName(cur.Methods, func(m spec.MethodDecl) string { return m.Name })
for name, p := range pmeth {
c, ok := cmeth[name]
if !ok {
changes = append(changes, fmt.Sprintf("removed method: %s", name))
continue
}
if !typeRefEqual(p.Returns, c.Returns) {
changes = append(changes, fmt.Sprintf(
"method %s return changed: %s → %s",
name, formatTypeRef(p.Returns), formatTypeRef(c.Returns)))
}
}
for name := range cmeth {
if _, ok := pmeth[name]; !ok {
changes = append(changes, fmt.Sprintf("added method: %s", name))
}
}
return changes
}
func indexByName[T any](xs []T, f func(T) string) map[string]T {
out := map[string]T{}
for _, x := range xs {
out[f(x)] = x
}
return out
}
func typeRefEqual(a, b spec.TypeRef) bool {
if a.Kind != b.Kind || a.Name != b.Name {
return false
}
if (a.ElemType == nil) != (b.ElemType == nil) {
return false
}
if a.ElemType != nil && !typeRefEqual(*a.ElemType, *b.ElemType) {
return false
}
return sameSet(a.Variants, b.Variants)
}
func formatTypeRef(t spec.TypeRef) string {
switch t.Kind {
case spec.KindPrimitive:
return t.Name
case spec.KindNamed:
return t.Name
case spec.KindArray:
if t.ElemType != nil {
return "[]" + formatTypeRef(*t.ElemType)
}
return "[]any"
case spec.KindOneOf:
return "(" + strings.Join(t.Variants, " | ") + ")"
}
return "?"
}
+216
View File
@@ -0,0 +1,216 @@
package main
import (
"testing"
"github.com/lukaszraczylo/go-telegram/internal/spec"
"github.com/stretchr/testify/require"
)
// ---- auditBool -----------------------------------------------------------
func TestAuditBool_FlagsUnapprovedBoolMethod(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{Name: "getMe", Doc: "A simple method.", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
},
}
ov := &spec.Overrides{}
problems := auditBool(api, ov)
require.Len(t, problems, 1)
require.Contains(t, problems[0], "bool fallback: getMe")
}
func TestAuditBool_SkipsApprovedBoolMethod(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{Name: "setWebhook", Doc: "Use this to set webhook.", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
},
}
ov := &spec.Overrides{ApprovedBoolMethods: []string{"setWebhook"}}
problems := auditBool(api, ov)
require.Empty(t, problems)
}
func TestAuditBool_SkipsMethodWithReturnsTrueDoc(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{Name: "doThing", Doc: "Returns True on success.", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
},
}
ov := &spec.Overrides{}
problems := auditBool(api, ov)
require.Empty(t, problems)
}
func TestAuditBool_SkipsNonBoolMethods(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{Name: "getMe", Doc: "Gets user.", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
},
}
ov := &spec.Overrides{}
require.Empty(t, auditBool(api, ov))
}
// ---- auditAny ------------------------------------------------------------
func TestAuditAny_FlagsUnrecognisedOneOf(t *testing.T) {
api := &spec.API{
Types: []spec.TypeDecl{
{
Name: "Foo",
Fields: []spec.Field{
{Name: "Bar", JSONName: "bar", Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"A", "B", "C"}}},
},
},
},
}
out := auditAny(api)
require.Len(t, out, 1)
require.Contains(t, out[0], "any field: Foo.Bar")
}
func TestAuditAny_SkipsChatIDShape(t *testing.T) {
api := &spec.API{
Types: []spec.TypeDecl{
{
Name: "SendMessage",
Fields: []spec.Field{
{Name: "ChatID", JSONName: "chat_id", Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"int64", "string"}}},
},
},
},
}
require.Empty(t, auditAny(api))
}
func TestAuditAny_SkipsKnownUnion(t *testing.T) {
api := &spec.API{
Types: []spec.TypeDecl{
{Name: "InputMedia", OneOf: []string{"InputMediaPhoto", "InputMediaVideo"}},
{
Name: "SomeMethod",
Fields: []spec.Field{
{Name: "Media", JSONName: "media", Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"InputMediaPhoto", "InputMediaVideo"}}},
},
},
},
}
require.Empty(t, auditAny(api))
}
func TestAuditAny_SkipsReplyMarkupShape(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{
Name: "sendMessage",
Params: []spec.Field{
{Name: "ReplyMarkup", JSONName: "reply_markup", Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"InlineKeyboardMarkup", "ReplyKeyboardMarkup", "ReplyKeyboardRemove", "ForceReply"}}},
},
},
},
}
require.Empty(t, auditAny(api))
}
func TestAuditAny_SkipsInputFileShape(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{
Name: "sendPhoto",
Params: []spec.Field{
{Name: "Photo", JSONName: "photo", Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"InputFile", "string"}}},
},
},
},
}
require.Empty(t, auditAny(api))
}
// ---- diffSignatures ------------------------------------------------------
func TestDiffSignatures_AddedMethod(t *testing.T) {
prev := &spec.API{}
cur := &spec.API{
Methods: []spec.MethodDecl{
{Name: "newMethod", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
},
}
changes := diffSignatures(prev, cur)
require.Len(t, changes, 1)
require.Contains(t, changes[0], "added method: newMethod")
}
func TestDiffSignatures_RemovedMethod(t *testing.T) {
prev := &spec.API{
Methods: []spec.MethodDecl{
{Name: "oldMethod", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
},
}
cur := &spec.API{}
changes := diffSignatures(prev, cur)
require.Len(t, changes, 1)
require.Contains(t, changes[0], "removed method: oldMethod")
}
func TestDiffSignatures_ChangedReturn(t *testing.T) {
prev := &spec.API{
Methods: []spec.MethodDecl{
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
},
}
cur := &spec.API{
Methods: []spec.MethodDecl{
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
},
}
changes := diffSignatures(prev, cur)
require.Len(t, changes, 1)
require.Contains(t, changes[0], "getMe")
require.Contains(t, changes[0], "bool")
require.Contains(t, changes[0], "User")
}
func TestDiffSignatures_Clean(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{Name: "getMe", Returns: spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
},
}
require.Empty(t, diffSignatures(api, api))
}
// ---- typeRefEqual --------------------------------------------------------
func TestTypeRefEqual_Primitive(t *testing.T) {
a := spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}
require.True(t, typeRefEqual(a, a))
b := spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}
require.False(t, typeRefEqual(a, b))
}
func TestTypeRefEqual_Array(t *testing.T) {
elem := &spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}
a := spec.TypeRef{Kind: spec.KindArray, ElemType: elem}
b := spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}}
require.True(t, typeRefEqual(a, b))
c := spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}}
require.False(t, typeRefEqual(a, c))
}
func TestTypeRefEqual_OneOf(t *testing.T) {
a := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"int64", "string"}}
b := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"string", "int64"}}
require.True(t, typeRefEqual(a, b))
c := spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"int64"}}
require.False(t, typeRefEqual(a, c))
}
func TestTypeRefEqual_NilVsNonNilElem(t *testing.T) {
a := spec.TypeRef{Kind: spec.KindArray}
b := spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}}
require.False(t, typeRefEqual(a, b))
}
+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}}
+211
View File
@@ -0,0 +1,211 @@
package main
import (
"testing"
"github.com/lukaszraczylo/go-telegram/internal/spec"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// parseTypeRef — edge cases
// ---------------------------------------------------------------------------
func TestParseTypeRef_Empty(t *testing.T) {
// Empty string → named with empty name (fallback).
got := parseTypeRef("")
require.Equal(t, spec.KindNamed, got.Kind)
require.Equal(t, "", got.Name)
}
func TestParseTypeRef_Whitespace(t *testing.T) {
got := parseTypeRef(" Integer ")
require.Equal(t, spec.KindPrimitive, got.Kind)
require.Equal(t, "int64", got.Name)
}
func TestParseTypeRef_True(t *testing.T) {
got := parseTypeRef("True")
require.Equal(t, spec.KindPrimitive, got.Kind)
require.Equal(t, "bool", got.Name)
}
func TestParseTypeRef_False(t *testing.T) {
got := parseTypeRef("False")
require.Equal(t, spec.KindPrimitive, got.Kind)
require.Equal(t, "bool", got.Name)
}
func TestParseTypeRef_FloatNumber(t *testing.T) {
got := parseTypeRef("Float number")
require.Equal(t, spec.KindPrimitive, got.Kind)
require.Equal(t, "float64", got.Name)
}
func TestParseTypeRef_Int(t *testing.T) {
got := parseTypeRef("Int")
require.Equal(t, spec.KindPrimitive, got.Kind)
require.Equal(t, "int64", got.Name)
}
func TestParseTypeRef_Bool(t *testing.T) {
got := parseTypeRef("Bool")
require.Equal(t, spec.KindPrimitive, got.Kind)
require.Equal(t, "bool", got.Name)
}
func TestParseTypeRef_CommaAndUnion(t *testing.T) {
// "Foo, Bar and Baz" → oneOf{Foo, Bar, Baz}
got := parseTypeRef("InputMediaPhoto, InputMediaVideo and InputMediaDocument")
require.Equal(t, spec.KindOneOf, got.Kind)
require.Len(t, got.Variants, 3)
require.Contains(t, got.Variants, "InputMediaPhoto")
require.Contains(t, got.Variants, "InputMediaVideo")
require.Contains(t, got.Variants, "InputMediaDocument")
}
func TestParseTypeRef_ArrayOfNothing(t *testing.T) {
// "Array of " with trailing space — TrimSpace removes the trailing space
// leaving "Array of" which does NOT match the "Array of " prefix, so it
// falls through to primitiveOrNamed and returns KindNamed (not KindArray).
got := parseTypeRef("Array of ")
require.Equal(t, spec.KindNamed, got.Kind)
}
// ---------------------------------------------------------------------------
// splitCommaAnd
// ---------------------------------------------------------------------------
func TestSplitCommaAnd_ThreeVariants(t *testing.T) {
got := splitCommaAnd("A, B and C")
require.Equal(t, []string{"A", "B", "C"}, got)
}
func TestSplitCommaAnd_FourVariants(t *testing.T) {
got := splitCommaAnd("A, B, C and D")
require.Equal(t, []string{"A", "B", "C", "D"}, got)
}
func TestSplitCommaAnd_ExtraSpaces(t *testing.T) {
got := splitCommaAnd(" Foo , Bar and Baz ")
require.Len(t, got, 3)
}
// ---------------------------------------------------------------------------
// goName — edge cases
// ---------------------------------------------------------------------------
func TestGoName_Empty(t *testing.T) {
require.Equal(t, "", goName(""))
}
func TestGoName_SingleWord(t *testing.T) {
require.Equal(t, "Photo", goName("photo"))
}
func TestGoName_JSON(t *testing.T) {
require.Equal(t, "JSON", goName("json"))
}
func TestGoName_HTML(t *testing.T) {
require.Equal(t, "HTML", goName("html"))
}
func TestGoName_HTTPS(t *testing.T) {
require.Equal(t, "HTTPS", goName("https"))
}
func TestGoName_AlreadyUpperSegment(t *testing.T) {
// Segment that starts with uppercase letter should be passed through.
require.Equal(t, "MediaGroupID", goName("media_group_id"))
}
// ---------------------------------------------------------------------------
// extractReturn — additional patterns
// ---------------------------------------------------------------------------
func TestExtractReturn_ArrayPattern(t *testing.T) {
desc := "Returns an Array of Update objects."
got := extractReturn(desc)
require.Equal(t, spec.KindArray, got.Kind)
require.Equal(t, "Update", got.ElemType.Name)
}
func TestExtractReturn_BoolPattern(t *testing.T) {
desc := "Returns True on success."
got := extractReturn(desc)
require.Equal(t, spec.KindPrimitive, got.Kind)
require.Equal(t, "bool", got.Name)
}
func TestExtractReturn_OnSuccessTrueIsReturned(t *testing.T) {
desc := "On success, true is returned."
got := extractReturn(desc)
require.Equal(t, spec.KindPrimitive, got.Kind)
require.Equal(t, "bool", got.Name)
}
func TestExtractReturn_NamedObject(t *testing.T) {
desc := "On success, returns a Message object."
got := extractReturn(desc)
require.Equal(t, spec.KindNamed, got.Kind)
require.Equal(t, "Message", got.Name)
}
func TestExtractReturn_MessageOrBool(t *testing.T) {
desc := "On success, the edited Message is returned, otherwise True is returned."
got := extractReturn(desc)
require.Equal(t, spec.KindNamed, got.Kind)
require.Equal(t, "MessageOrBool", got.Name)
}
func TestExtractReturn_InFormOf(t *testing.T) {
desc := "The answer is provided in form of a ChatInviteLink object."
got := extractReturn(desc)
require.Equal(t, spec.KindNamed, got.Kind)
require.Equal(t, "ChatInviteLink", got.Name)
}
func TestExtractReturn_Fallback(t *testing.T) {
// No recognized pattern → bool fallback.
got := extractReturn("This method does something interesting.")
require.Equal(t, spec.KindPrimitive, got.Kind)
require.Equal(t, "bool", got.Name)
}
func TestExtractReturn_MultipleReturnsFirstWins(t *testing.T) {
// Doc with multiple "Returns" phrases — first matching pattern should win.
// The indefinite-article pattern ("Returns a X object") appears earlier in
// the priority list than "Returns True", so it matches "Returns a Message"
// before the bool pattern can fire.
desc := "Returns True on success. You can also Returns a Message object later."
got := extractReturn(desc)
// The indefinite-article pattern fires first → returns Message (KindNamed).
require.Equal(t, spec.KindNamed, got.Kind)
require.Equal(t, "Message", got.Name)
}
// ---------------------------------------------------------------------------
// extractVersion
// ---------------------------------------------------------------------------
func TestExtractVersion_InTitle(t *testing.T) {
sections := []section{
{Title: "Bot API 7.3", Description: ""},
}
require.Equal(t, "7.3", extractVersion(sections))
}
func TestExtractVersion_InDescription(t *testing.T) {
sections := []section{
{Title: "April 2024", Description: "Released Bot API 7.2."},
}
require.Equal(t, "7.2", extractVersion(sections))
}
func TestExtractVersion_NotFound(t *testing.T) {
sections := []section{
{Title: "Introduction", Description: "Welcome to the API."},
}
require.Equal(t, "", extractVersion(sections))
}
+77
View File
@@ -0,0 +1,77 @@
// Command scrape parses the Telegram Bot API HTML page into the IR
// (internal/spec.API) and writes it to internal/spec/api.json.
//
// Usage:
//
// scrape -input <file> (read HTML from local file)
// scrape -url <url> (fetch HTML from URL; default: live docs)
// scrape -output <file> (output path; default: internal/spec/api.json)
package main
import (
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
const defaultURL = "https://core.telegram.org/bots/api"
func main() {
input := flag.String("input", "", "local HTML file (overrides -url)")
url := flag.String("url", defaultURL, "URL to fetch HTML from")
output := flag.String("output", "internal/spec/api.json", "output path")
overridesPath := flag.String("overrides", "internal/spec/overrides.json", "path to overrides JSON")
flag.Parse()
if err := run(*input, *url, *output, *overridesPath); err != nil {
fmt.Fprintln(os.Stderr, "scrape:", err)
os.Exit(1)
}
}
func run(input, url, output, overridesPath string) error {
htmlBytes, err := readHTML(input, url)
if err != nil {
return fmt.Errorf("read html: %w", err)
}
api, err := scrape(htmlBytes)
if err != nil {
return fmt.Errorf("scrape: %w", err)
}
overrides, err := spec.LoadOverrides(overridesPath)
if err != nil {
return fmt.Errorf("load overrides: %w", err)
}
overrides.Apply(api)
return writeJSON(output, api)
}
func readHTML(input, url string) ([]byte, error) {
if input != "" {
return os.ReadFile(input)
}
c := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "go-telegram codegen scraper")
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, errors.New(resp.Status)
}
return io.ReadAll(resp.Body)
}
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"regexp"
"strings"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
// extractReturn pulls the return type from a method's description prose.
//
// Patterns we handle (in priority order):
//
// "Returns an Array of X" / "On success, an Array of X is returned" → array of named X
// "an array of X of the sent messages is returned" → array of named X
// "the edited X is returned, otherwise True is returned" → XOrBool
// "Returns ... as a X object" / "Returns ... as X object" → named X
// "Returns ... as String on success" → string
// "On success, returns a X object" / "Returns a X object" → named X (indefinite article)
// "On success, an? X is returned" / "On success, the X is returned" → named X
// "Returns True" / "On success, true is returned" → bool
// "Returns the verb-ed X" → named X
// "On success, X is returned" → named X
// "Returns X on success" (no article) → named X
// "in form of a X" → named X
// fallback: bool
func extractReturn(desc string) spec.TypeRef {
// Normalise; strip *bold* markers because Telegram uses italics.
d := strings.ReplaceAll(desc, "*", "")
patterns := []struct {
re *regexp.Regexp
fn func([]string) spec.TypeRef
}{
// Array patterns first — most specific.
{regexp.MustCompile(`Returns an? [Aa]rray of ([A-Z][A-Za-z0-9]+)`), func(m []string) spec.TypeRef {
elem := primitiveOrNamed(m[1])
return spec.TypeRef{Kind: spec.KindArray, ElemType: &elem}
}},
{regexp.MustCompile(`On success(?:,)?\s+(?:an?\s+)?[Aa]rray of ([A-Z][A-Za-z0-9]+)(?:\s+objects?)?\s+(?:is|are|that\s+\S+\s+\S+\s+)?(?:is |are )?returned`), func(m []string) spec.TypeRef {
elem := primitiveOrNamed(m[1])
return spec.TypeRef{Kind: spec.KindArray, ElemType: &elem}
}},
// "an array of X of the sent messages is returned" (ForwardMessages/CopyMessages shape).
{regexp.MustCompile(`(?:[Oo]n success[,.]?\s+)?an? array of ([A-Z][A-Za-z0-9]+)(?:\s+of [^.]+?)?\s+(?:objects\s+)?(?:is|are) returned`), func(m []string) spec.TypeRef {
elem := primitiveOrNamed(m[1])
return spec.TypeRef{Kind: spec.KindArray, ElemType: &elem}
}},
// "Message or True" conditional return → XOrBool sentinel.
{regexp.MustCompile(`the (?:edited|sent|stopped)?\s*([A-Z][A-Za-z0-9]+)\s+is returned, otherwise (?:True|true) is returned`), func(m []string) spec.TypeRef {
return spec.TypeRef{Kind: spec.KindNamed, Name: m[1] + "OrBool"}
}},
// "Returns ... as a X object" / "Returns ... as X object" (with or without article).
{regexp.MustCompile(`[Rr]eturns? (?:.+? )?as (?:an? )?([A-Z][A-Za-z0-9]+) object`), func(m []string) spec.TypeRef {
return primitiveOrNamed(m[1])
}},
// "Returns ... as String on success" / "Returns ... as X on success" (named type after "as").
{regexp.MustCompile(`[Rr]eturns? (?:.+? )?as ([A-Z][A-Za-z0-9]+) on success`), func(m []string) spec.TypeRef {
return primitiveOrNamed(m[1])
}},
// Indefinite article: "On success, returns a X object" / "Returns a X object".
{regexp.MustCompile(`(?:[Oo]n success[,.]?\s+)?[Rr]eturns? an? ([A-Z][A-Za-z0-9]+)(?:\s+object)?`), func(m []string) spec.TypeRef {
return primitiveOrNamed(m[1])
}},
// "On success, an? X is returned" / "On success, the stopped X is returned".
{regexp.MustCompile(`On success,\s+(?:an?|the)?\s*(?:[a-z]+\s+)?([A-Z][A-Za-z0-9]+)(?:\s+object)?\s+is returned`), func(m []string) spec.TypeRef {
return primitiveOrNamed(m[1])
}},
// Explicit True — must come before the broad "Returns X" pattern.
{regexp.MustCompile(`Returns True`), func(m []string) spec.TypeRef {
return spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}
}},
{regexp.MustCompile(`(?i)on success, true is returned`), func(m []string) spec.TypeRef {
return spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}
}},
// "Returns the verb-ed X" — accepts any verb prefix (uploaded, revoked, …).
{regexp.MustCompile(`Returns (?:the|an?)\s+(?:[a-z]+ )?([A-Z][A-Za-z0-9]+)`), func(m []string) spec.TypeRef {
return primitiveOrNamed(m[1])
}},
// "On success, X is returned" (no article).
{regexp.MustCompile(`On success(?:,)?\s+(?:the\s+)?(?:newly\s+)?(?:edited\s+|sent\s+|created\s+|updated\s+)?([A-Z][A-Za-z0-9]+)\s+is returned`), func(m []string) spec.TypeRef {
return primitiveOrNamed(m[1])
}},
// "Returns X on success" (no article, e.g. "Returns OwnedGifts on success").
{regexp.MustCompile(`[Rr]eturns ([A-Z][A-Za-z0-9]+) on success`), func(m []string) spec.TypeRef {
return primitiveOrNamed(m[1])
}},
// "in form of a X".
{regexp.MustCompile(`in (?:the )?form of (?:a )?([A-Z][A-Za-z0-9]+)`), func(m []string) spec.TypeRef {
return primitiveOrNamed(m[1])
}},
}
for _, p := range patterns {
if m := p.re.FindStringSubmatch(d); m != nil {
return p.fn(m)
}
}
// Fallback: bool. Better than panic; method-by-method tests would
// catch any regression.
return spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}
}
// hasFilesParams returns true if any param mentions InputFile (the
// scraper convention triggering multipart/form-data).
func hasFilesParams(params []spec.Field) bool {
for _, p := range params {
if mentionsInputFile(p.Type) {
return true
}
}
return false
}
func mentionsInputFile(tr spec.TypeRef) bool {
switch tr.Kind {
case spec.KindNamed:
return tr.Name == "InputFile" || strings.HasPrefix(tr.Name, "InputMedia") || strings.HasPrefix(tr.Name, "InputPaidMedia")
case spec.KindArray:
if tr.ElemType != nil {
return mentionsInputFile(*tr.ElemType)
}
case spec.KindOneOf:
for _, v := range tr.Variants {
if v == "InputFile" || strings.HasPrefix(v, "InputMedia") || strings.HasPrefix(v, "InputPaidMedia") {
return true
}
}
}
return false
}
// extractVersion finds the API version string in a "Bot API X.Y[.Z]" heading.
var versionRE = regexp.MustCompile(`Bot API (\d+\.\d+(?:\.\d+)?)`)
// extractVersion finds the API version string. The live docs page emits
// the version as "<strong>Bot API X.Y</strong>" inside a paragraph below
// a date heading; the small fixture uses an h4 "Bot API X.Y" instead.
// Both shapes are handled here by also scanning section descriptions.
func extractVersion(sections []section) string {
for _, s := range sections {
if m := versionRE.FindStringSubmatch(s.Title); m != nil {
return m[1]
}
if m := versionRE.FindStringSubmatch(s.Description); m != nil {
return m[1]
}
}
return ""
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
func TestExtractReturn(t *testing.T) {
cases := []struct {
in string
want spec.TypeRef
}{
{"Returns basic information about the bot in form of a User object.", spec.TypeRef{Kind: spec.KindNamed, Name: "User"}},
{"On success, the sent Message is returned.", spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}},
{"Returns an Array of Update objects.", spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}}},
{"Returns True on success.", spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
{"On success, True is returned.", spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
// Issue 5: "Message or True" conditional return → MessageOrBool sentinel.
{"On success, if the edited message is not an inline message, the edited Message is returned, otherwise True is returned.", spec.TypeRef{Kind: spec.KindNamed, Name: "MessageOrBool"}},
// Issue 1: new phrasings.
{"On success, returns a WebhookInfo object.", spec.TypeRef{Kind: spec.KindNamed, Name: "WebhookInfo"}},
{"Returns a UserProfilePhotos object.", spec.TypeRef{Kind: spec.KindNamed, Name: "UserProfilePhotos"}},
{"Returns the uploaded File.", spec.TypeRef{Kind: spec.KindNamed, Name: "File"}},
{"On success, the stopped Poll is returned.", spec.TypeRef{Kind: spec.KindNamed, Name: "Poll"}},
{"On success, an Array of MessageId is returned.", spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "MessageId"}}},
{"On success, an array of Message objects that were sent is returned.", spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}}},
// ForwardMessages/CopyMessages shape: "an array of X of the sent messages is returned".
{"On success, an array of MessageId of the sent messages is returned.", spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "MessageId"}}},
// "Returns X on success" (no article) — OwnedGifts, StarAmount, Story, MenuButton, etc.
{"Returns the gifts received and owned by a managed business account. Returns OwnedGifts on success.", spec.TypeRef{Kind: spec.KindNamed, Name: "OwnedGifts"}},
{"Returns StarAmount on success.", spec.TypeRef{Kind: spec.KindNamed, Name: "StarAmount"}},
{"Posts a story on behalf of a managed business account. Returns Story on success.", spec.TypeRef{Kind: spec.KindNamed, Name: "Story"}},
{"Returns MenuButton on success.", spec.TypeRef{Kind: spec.KindNamed, Name: "MenuButton"}},
// "Returns ... as X object" (no article before type) — ChatInviteLink variants.
{"Returns the new invite link as ChatInviteLink object.", spec.TypeRef{Kind: spec.KindNamed, Name: "ChatInviteLink"}},
{"Returns the revoked invite link as ChatInviteLink object.", spec.TypeRef{Kind: spec.KindNamed, Name: "ChatInviteLink"}},
// "Returns ... as a X object" (with article) — createForumTopic.
{"Returns information about the created topic as a ForumTopic object.", spec.TypeRef{Kind: spec.KindNamed, Name: "ForumTopic"}},
// "Returns ... as String on success" — exportChatInviteLink / createInvoiceLink.
{"Returns the new invite link as String on success.", spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}},
{"Returns the created invoice link as String on success.", spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}},
// "Returns Int on success" — getChatMemberCount.
{"Returns Int on success.", spec.TypeRef{Kind: spec.KindPrimitive, Name: "int64"}},
}
for _, c := range cases {
require.Equal(t, c.want, extractReturn(c.in), c.in)
}
}
func TestHasFilesParams(t *testing.T) {
require.True(t, hasFilesParams([]spec.Field{
{Type: spec.TypeRef{Kind: spec.KindNamed, Name: "InputFile"}},
}))
require.True(t, hasFilesParams([]spec.Field{
{Type: spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"InputFile", "string"}}},
}))
require.False(t, hasFilesParams([]spec.Field{
{Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}},
}))
// Issue 2: Array of InputMedia* union triggers HasFiles.
require.True(t, hasFilesParams([]spec.Field{
{Type: spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"InputMediaPhoto", "InputMediaVideo"}}}},
}))
}
func TestExtractVersion(t *testing.T) {
sections := []section{{Title: "Recent changes"}, {Title: "Bot API 7.10"}, {Title: "Available types"}}
require.Equal(t, "7.10", extractVersion(sections))
// Issue 4: 3-part version must not be truncated.
sections3 := []section{{Description: "Bot API 8.0.1"}}
require.Equal(t, "8.0.1", extractVersion(sections3))
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"bytes"
"fmt"
"github.com/goccy/go-json"
"os"
"golang.org/x/net/html"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
// scrape (the package-level implementation overriding the stub in main.go;
// remove the stub from main.go in this task) parses the docs HTML into IR.
func scrape(htmlBytes []byte) (*spec.API, error) {
doc, err := html.Parse(bytes.NewReader(htmlBytes))
if err != nil {
return nil, fmt.Errorf("html parse: %w", err)
}
sections := walk(doc)
api := &spec.API{Version: extractVersion(sections)}
for _, s := range sections {
switch {
case isMethodTitle(s.Title):
api.Methods = append(api.Methods, methodFromSection(s))
case isTypeTitle(s.Title):
api.Types = append(api.Types, typeFromSection(s))
}
}
return api, nil
}
func typeFromSection(s section) spec.TypeDecl {
td := spec.TypeDecl{Name: s.Title, Doc: s.Description}
if len(s.Tables) > 0 {
td.Fields = parseFieldsTable(s.Tables[0])
} else if len(s.Lists) > 0 {
// Union: extract variant names from <li><a>...</a></li>.
td.OneOf = extractListLinks(s.Lists[0])
}
return td
}
func methodFromSection(s section) spec.MethodDecl {
md := spec.MethodDecl{Name: s.Title, Doc: s.Description, Returns: extractReturn(s.Description)}
if len(s.Tables) > 0 {
md.Params = parseParamsTable(s.Tables[0])
}
md.HasFiles = hasFilesParams(md.Params)
return md
}
// extractListLinks pulls anchor texts out of a <ul>: each <li><a>X</a></li>
// contributes "X" to the result. Used for union variant lists.
func extractListLinks(ul *html.Node) []string {
var names []string
var visit func(*html.Node)
visit = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "a" {
names = append(names, textOf(n))
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
visit(c)
}
}
visit(ul)
return names
}
// writeJSON marshals the IR with stable, human-readable formatting and
// writes it to path. Marshalling is deterministic: types and methods
// preserve scrape order; struct fields use IR-defined order.
func writeJSON(path string, api *spec.API) error {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
if err := enc.Encode(api); err != nil {
return err
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"os"
"testing"
"github.com/stretchr/testify/require"
)
var update = flag.Bool("update", false, "update golden files")
func TestScrape_Golden_SmallFixture(t *testing.T) {
htmlBytes, err := os.ReadFile("../../testdata/html/small_fixture.html")
require.NoError(t, err)
api, err := scrape(htmlBytes)
require.NoError(t, err)
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
require.NoError(t, enc.Encode(api))
goldenPath := "../../testdata/golden/api_small_fixture.json"
if *update {
require.NoError(t, os.WriteFile(goldenPath, buf.Bytes(), 0o644))
return
}
expected, err := os.ReadFile(goldenPath)
require.NoError(t, err, "missing golden; run `go test -update ./cmd/scrape/...` to create")
require.Equal(t, string(expected), buf.String())
}
+224
View File
@@ -0,0 +1,224 @@
package main
import (
"strings"
"golang.org/x/net/html"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
// parseFieldsTable walks a <table> for an object-type definition.
// Columns: Field, Type, Description (optional column orders are not
// supported; Telegram's docs use a stable layout).
//
// Optional fields are detected via the "Optional." prefix in the
// description text, which is the documented convention.
func parseFieldsTable(t *html.Node) []spec.Field {
rows := tableRows(t)
if len(rows) == 0 {
return nil
}
var fields []spec.Field
for _, row := range rows[1:] { // skip header
cells := rowCells(row)
if len(cells) < 3 {
continue
}
jname := strings.TrimSpace(textOf(cells[0]))
typeText := strings.TrimSpace(textOf(cells[1]))
desc := strings.TrimSpace(textOf(cells[2]))
required := !strings.HasPrefix(desc, "Optional.")
fields = append(fields, spec.Field{
Name: goName(jname),
JSONName: jname,
Type: parseTypeRef(typeText),
Required: required,
Doc: desc,
})
}
return fields
}
// parseParamsTable walks a <table> for a method definition.
// Columns: Parameter, Type, Required, Description.
func parseParamsTable(t *html.Node) []spec.Field {
rows := tableRows(t)
if len(rows) == 0 {
return nil
}
var params []spec.Field
for _, row := range rows[1:] {
cells := rowCells(row)
if len(cells) < 4 {
continue
}
jname := strings.TrimSpace(textOf(cells[0]))
typeText := strings.TrimSpace(textOf(cells[1]))
req := strings.EqualFold(strings.TrimSpace(textOf(cells[2])), "Yes")
desc := strings.TrimSpace(textOf(cells[3]))
params = append(params, spec.Field{
Name: goName(jname),
JSONName: jname,
Type: parseTypeRef(typeText),
Required: req,
Doc: desc,
})
}
return params
}
// tableRows returns the <tr> children of a <table>, skipping over
// any <thead>/<tbody> wrappers.
func tableRows(t *html.Node) []*html.Node {
var rows []*html.Node
var visit func(*html.Node)
visit = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "tr" {
rows = append(rows, n)
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
visit(c)
}
}
visit(t)
return rows
}
// rowCells returns the <td> (or <th>) children of a <tr>.
func rowCells(tr *html.Node) []*html.Node {
var cells []*html.Node
for c := tr.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && (c.Data == "td" || c.Data == "th") {
cells = append(cells, c)
}
}
return cells
}
// goName converts a snake_case JSON identifier to PascalCase.
// Special-cases common acronyms used in the Telegram docs.
func goName(s string) string {
if s == "" {
return ""
}
parts := strings.Split(s, "_")
var b strings.Builder
for _, p := range parts {
if p == "" {
continue
}
switch p {
case "id":
b.WriteString("ID")
case "url":
b.WriteString("URL")
case "ip":
b.WriteString("IP")
case "https":
b.WriteString("HTTPS")
case "json":
b.WriteString("JSON")
case "html":
b.WriteString("HTML")
default:
if p[0] >= 'a' && p[0] <= 'z' {
b.WriteByte(p[0] - 'a' + 'A')
b.WriteString(p[1:])
} else {
b.WriteString(p)
}
}
}
return b.String()
}
// parseTypeRef decodes the type-cell text into a spec.TypeRef.
//
// Recognised shapes:
//
// "Integer" → primitive int64
// "String" → primitive string
// "Boolean" / "True" → primitive bool
// "Float" / "Float number"→ primitive float64
// "Array of X" → array of (parseTypeRef of X)
// "Array of Array of X" → array of array of X
// "Foo" → named Foo
// "Foo or Bar" → oneOf {Foo, Bar}
// "InputFile or String" → oneOf (caller may translate to InputFile)
//
// parseTypeRef decodes the type-cell text into a spec.TypeRef.
//
// Recognised shapes:
//
// "Integer" → primitive int64
// "String" → primitive string
// "Boolean" / "True" → primitive bool
// "Float" / "Float number"→ primitive float64
// "Array of X" → array of (parseTypeRef of X)
// "Array of Array of X" → array of array of X
// "Foo" → named Foo
// "Foo or Bar" → oneOf {Foo, Bar}
// "Foo, Bar and Baz" → oneOf {Foo, Bar, Baz} (Telegram's comma+and union form)
// "InputFile or String" → oneOf (caller may translate to InputFile)
func parseTypeRef(s string) spec.TypeRef {
s = strings.TrimSpace(s)
// Array prefix.
if rest, ok := strings.CutPrefix(s, "Array of "); ok {
elem := parseTypeRef(rest)
return spec.TypeRef{Kind: spec.KindArray, ElemType: &elem}
}
// Comma-and union ("X, Y, Z and W") — used by Telegram for ≥3-variant unions.
if strings.Contains(s, ", ") && strings.Contains(s, " and ") {
parts := splitCommaAnd(s)
variants := make([]string, 0, len(parts))
for _, p := range parts {
variants = append(variants, primitiveOrNamed(strings.TrimSpace(p)).Name)
}
return spec.TypeRef{Kind: spec.KindOneOf, Variants: variants}
}
// "X or Y" union (the 2-variant form).
if strings.Contains(s, " or ") {
parts := strings.Split(s, " or ")
variants := make([]string, 0, len(parts))
for _, p := range parts {
variants = append(variants, primitiveOrNamed(strings.TrimSpace(p)).Name)
}
return spec.TypeRef{Kind: spec.KindOneOf, Variants: variants}
}
return primitiveOrNamed(s)
}
// splitCommaAnd splits "A, B, C and D" → ["A", "B", "C", "D"].
func splitCommaAnd(s string) []string {
// Replace " and " with ", " then split on ", ".
s = strings.ReplaceAll(s, " and ", ", ")
parts := strings.Split(s, ", ")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// primitiveOrNamed maps a single-word type cell to either a primitive
// or a named TypeRef. Unrecognised words are treated as named types.
func primitiveOrNamed(s string) spec.TypeRef {
switch s {
case "Integer", "Int":
return spec.TypeRef{Kind: spec.KindPrimitive, Name: "int64"}
case "String":
return spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}
case "Boolean", "Bool", "True", "False":
return spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}
case "Float", "Float number":
return spec.TypeRef{Kind: spec.KindPrimitive, Name: "float64"}
default:
return spec.TypeRef{Kind: spec.KindNamed, Name: s}
}
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
func TestGoName(t *testing.T) {
cases := []struct{ in, want string }{
{"chat_id", "ChatID"},
{"first_name", "FirstName"},
{"is_bot", "IsBot"},
{"url", "URL"},
{"ip_address", "IPAddress"},
{"language_code", "LanguageCode"},
{"webhook_URL", "WebhookURL"}, // Issue 3: already-uppercase segment must not be corrupted.
}
for _, c := range cases {
require.Equal(t, c.want, goName(c.in), c.in)
}
}
func TestParseTypeRef(t *testing.T) {
cases := []struct {
in string
want spec.TypeRef
}{
{"Integer", spec.TypeRef{Kind: spec.KindPrimitive, Name: "int64"}},
{"String", spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"}},
{"Boolean", spec.TypeRef{Kind: spec.KindPrimitive, Name: "bool"}},
{"Float", spec.TypeRef{Kind: spec.KindPrimitive, Name: "float64"}},
{"Message", spec.TypeRef{Kind: spec.KindNamed, Name: "Message"}},
{"Array of Update", spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "Update"}}},
{"Array of Array of PhotoSize", spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindArray, ElemType: &spec.TypeRef{Kind: spec.KindNamed, Name: "PhotoSize"}}}},
{"Integer or String", spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"int64", "string"}}},
{"InputFile or String", spec.TypeRef{Kind: spec.KindOneOf, Variants: []string{"InputFile", "string"}}},
}
for _, c := range cases {
require.Equal(t, c.want, parseTypeRef(c.in), c.in)
}
}
func TestParseFieldsTable_FromFixture(t *testing.T) {
doc := parse(t, "../../testdata/html/small_fixture.html")
sections := walk(doc)
var user *section
for i := range sections {
if sections[i].Title == "User" {
user = &sections[i]
break
}
}
require.NotNil(t, user)
require.Len(t, user.Tables, 1)
fields := parseFieldsTable(user.Tables[0])
require.Len(t, fields, 4)
require.Equal(t, "ID", fields[0].Name)
require.Equal(t, "id", fields[0].JSONName)
require.Equal(t, spec.KindPrimitive, fields[0].Type.Kind)
require.True(t, fields[0].Required)
require.Equal(t, "LastName", fields[3].Name)
require.False(t, fields[3].Required) // "Optional." prefix
}
func TestParseParamsTable_FromFixture(t *testing.T) {
doc := parse(t, "../../testdata/html/small_fixture.html")
sections := walk(doc)
var sm *section
for i := range sections {
if sections[i].Title == "sendMessage" {
sm = &sections[i]
break
}
}
require.NotNil(t, sm)
require.Len(t, sm.Tables, 1)
params := parseParamsTable(sm.Tables[0])
require.Len(t, params, 3)
require.Equal(t, "ChatID", params[0].Name)
require.True(t, params[0].Required)
require.Equal(t, spec.KindOneOf, params[0].Type.Kind)
require.Equal(t, []string{"int64", "string"}, params[0].Type.Variants)
require.Equal(t, "ParseMode", params[2].Name)
require.False(t, params[2].Required) // "Optional"
}
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"strings"
"golang.org/x/net/html"
)
// section is an h4-anchored block of the docs page. Title is the
// heading text (e.g. "User" or "sendMessage"). Description is the
// concatenation of immediately-following <p> paragraphs (until the
// next h4 / h3 / table / list). Tables and Lists hold raw nodes for
// later parsing by the table/oneof extractors.
type section struct {
Title string
Description string
Tables []*html.Node // <table> nodes
Lists []*html.Node // <ul> nodes (used for oneof variant lists)
}
// walk parses the page and returns sections in document order.
// Sections whose title contains a space (e.g. "Bot API 7.10") are
// included; later passes ignore them or treat them specially.
func walk(doc *html.Node) []section {
var (
sections []section
current *section
)
var visit func(n *html.Node)
visit = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.Data {
case "h4":
if current != nil {
sections = append(sections, *current)
}
current = &section{Title: textOf(n)}
// Don't recurse into the heading; we already have its text.
return
case "h3":
// h3 (e.g. "Available methods") delimits a section;
// flush the current h4 section but do not start a new one.
if current != nil {
sections = append(sections, *current)
current = nil
}
return
case "p":
if current != nil {
if current.Description != "" {
current.Description += "\n"
}
current.Description += strings.TrimSpace(textOf(n))
}
return
case "table":
if current != nil {
current.Tables = append(current.Tables, n)
}
return
case "ul":
if current != nil {
current.Lists = append(current.Lists, n)
}
return
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
visit(c)
}
}
visit(doc)
if current != nil {
sections = append(sections, *current)
}
return sections
}
// textOf returns the concatenated text content of n and descendants,
// with adjacent whitespace collapsed to single spaces.
func textOf(n *html.Node) string {
var sb strings.Builder
var w func(*html.Node)
w = func(n *html.Node) {
if n.Type == html.TextNode {
sb.WriteString(n.Data)
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
w(c)
}
}
w(n)
return collapseWS(sb.String())
}
func collapseWS(s string) string {
var b strings.Builder
prevSpace := false
for _, r := range s {
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
if !prevSpace {
b.WriteByte(' ')
}
prevSpace = true
continue
}
prevSpace = false
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
// isMethodTitle returns true for headings that look like method names
// (camelCase starting with a lowercase letter; e.g. "sendMessage").
func isMethodTitle(s string) bool {
if s == "" {
return false
}
r := s[0]
return r >= 'a' && r <= 'z'
}
// isTypeTitle returns true for headings that look like type names
// (PascalCase; e.g. "Message"). Allows a leading-uppercase only;
// excludes spaces (which would denote a header like "Bot API 7.10").
func isTypeTitle(s string) bool {
if s == "" {
return false
}
r := s[0]
if r < 'A' || r > 'Z' {
return false
}
return !strings.Contains(s, " ")
}
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/net/html"
)
func parse(t *testing.T, path string) *html.Node {
t.Helper()
f, err := os.Open(path)
require.NoError(t, err)
defer f.Close()
doc, err := html.Parse(f)
require.NoError(t, err)
return doc
}
func TestWalk_FixtureSections(t *testing.T) {
doc := parse(t, "../../testdata/html/small_fixture.html")
sections := walk(doc)
titles := make([]string, 0, len(sections))
for _, s := range sections {
titles = append(titles, s.Title)
}
require.Contains(t, titles, "User")
require.Contains(t, titles, "ChatMember")
require.Contains(t, titles, "getMe")
require.Contains(t, titles, "sendMessage")
require.Contains(t, titles, "sendDocument")
require.Contains(t, titles, "getUpdates")
require.Contains(t, titles, "Bot API 7.10")
}
func TestIsMethodTitle(t *testing.T) {
require.True(t, isMethodTitle("sendMessage"))
require.True(t, isMethodTitle("getMe"))
require.False(t, isMethodTitle("Message"))
require.False(t, isMethodTitle(""))
require.False(t, isMethodTitle("Bot API 7.10"))
}
func TestIsTypeTitle(t *testing.T) {
require.True(t, isTypeTitle("Message"))
require.True(t, isTypeTitle("ChatMember"))
require.False(t, isTypeTitle("sendMessage"))
require.False(t, isTypeTitle("Bot API 7.10"))
require.False(t, isTypeTitle(""))
}
func TestSection_DescriptionAndTables(t *testing.T) {
doc := parse(t, "../../testdata/html/small_fixture.html")
sections := walk(doc)
var sm *section
for i, s := range sections {
if s.Title == "sendMessage" {
sm = &sections[i]
break
}
}
require.NotNil(t, sm)
require.True(t, strings.Contains(sm.Description, "Use this method to send text messages"))
require.Len(t, sm.Tables, 1)
}