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