Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 55 additions & 9 deletions host.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,11 @@ import (
"net/http"
)

// botHost represent information on current hosting platform
type botHost struct {
}

//var _ botsfw.BotHost = (*botHost)(nil)

// BotHost returns hosting platform settings & information
func BotHost() interface {
// botHostInterface is the shape returned by BotHost and BotHostWithHTTPClient.
// It intentionally mirrors github.com/bots-go-framework/bots-fw's botsfw.BotHost
// interface without importing that module, so this package keeps its single
// real dependency (google.golang.org/appengine/v2).
type botHostInterface interface {

// Context returns a context.Context for a request.
// We need this as some platforms (as Google App Engine Standard)
Expand All @@ -23,7 +20,19 @@ func BotHost() interface {
// GetHTTPClient returns HTTP client for current host
// We need this as some platforms (as Google App Engine Standard) require setting http client in a specific way.
GetHTTPClient(c context.Context) *http.Client
} {
}

// botHost represent information on current hosting platform
type botHost struct {
}

//var _ botsfw.BotHost = (*botHost)(nil)

// BotHost returns hosting platform settings & information. Its GetHTTPClient
// always returns http.DefaultClient; use BotHostWithHTTPClient when a caller
// needs to control outbound HTTP (e.g. to redirect Bot API calls to a
// Chatwright Telegram Platform Emulator in a non-production environment).
func BotHost() botHostInterface {
return botHost{}
}

Expand All @@ -46,6 +55,43 @@ func (h botHost) GetHTTPClient(c context.Context) *http.Client {
//}
}

// botHostWithClient is a botHostInterface whose GetHTTPClient returns a
// caller-supplied *http.Client instead of http.DefaultClient. Context()
// behaves identically to botHost's.
type botHostWithClient struct {
client *http.Client
}

// BotHostWithHTTPClient returns hosting platform settings & information whose
// GetHTTPClient returns client instead of http.DefaultClient. This is the
// seam a caller uses to redirect a bot's outbound HTTP calls — for example,
// installing a TelegramRedirectTransport to point Bot API traffic at a
// Chatwright emulator instead of https://api.telegram.org — without changing
// anything else about how the host behaves. It does not alter BotHost(),
// which keeps returning http.DefaultClient exactly as before.
//
// client must not be nil; callers that do not need a custom client should
// call BotHost() instead.
func BotHostWithHTTPClient(client *http.Client) botHostInterface {
if client == nil {
panic("bots-host-gae: client == nil")
}
return botHostWithClient{client: client}
}

// Context creates context for http.Request, identically to botHost.Context.
func (h botHostWithClient) Context(r *http.Request) context.Context {
return appengine.NewContext(r)
}

// GetHTTPClient returns the *http.Client supplied to BotHostWithHTTPClient.
func (h botHostWithClient) GetHTTPClient(c context.Context) *http.Client {
if c == nil {
panic("c == nil")
}
return h.client
}

//var DbProvider = func(c context.Context) (db dal.DB, err error) {
// panic("gae.DbProvider is not set")
// //return dalgo2datastore.NewDatabase(c, "")
Expand Down
65 changes: 65 additions & 0 deletions telegram_redirect_transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package gae

import (
"errors"
"fmt"
"net/http"
"net/url"
)

// TelegramAPIHost is the hostname of the real Telegram Bot API
// (github.com/bots-go-framework/bots-api-telegram's tgbotapi.APIEndpoint is
// hardcoded to this host — it has no configurable base URL). It is exported
// so callers can recognize which requests TelegramRedirectTransport rewrites.
const TelegramAPIHost = "api.telegram.org"

// TelegramRedirectTransport is an http.RoundTripper that rewrites requests
// bound for TelegramAPIHost to a caller-supplied base URL and rejects every
// other destination. It exists to let a non-production host (e.g. one wired
// to a Chatwright Telegram Platform Emulator) redirect a bot's outbound Bot
// API calls without turning the process's HTTP client into a general-purpose
// proxy: refusing any request whose host is not TelegramAPIHost is a closed
// network boundary, not an oversight.
//
// Construct one with NewTelegramRedirectTransport and install it on an
// *http.Client passed to BotHostWithHTTPClient.
type TelegramRedirectTransport struct {
baseURL *url.URL
base http.RoundTripper
}

// NewTelegramRedirectTransport returns a TelegramRedirectTransport that
// rewrites the scheme and host of any request whose destination host is
// TelegramAPIHost to baseURL, preserving the path, query and body. Requests
// to any other host are rejected with an error rather than forwarded.
//
// baseURL must be non-nil and specify a scheme and host (e.g.
// "http://127.0.0.1:4000"). base is the underlying RoundTripper used to
// perform the rewritten request; if nil, http.DefaultTransport is used.
func NewTelegramRedirectTransport(baseURL *url.URL, base http.RoundTripper) (*TelegramRedirectTransport, error) {
if baseURL == nil {
return nil, errors.New("bots-host-gae: baseURL is nil")
}
if baseURL.Scheme == "" || baseURL.Host == "" {
return nil, fmt.Errorf("bots-host-gae: baseURL %q must have a scheme and a host", baseURL.String())
}
if base == nil {
base = http.DefaultTransport
}
return &TelegramRedirectTransport{baseURL: baseURL, base: base}, nil
}

// RoundTrip implements http.RoundTripper. It rejects any request whose host
// is not TelegramAPIHost and otherwise forwards the request, rewritten to
// target the configured baseURL, to the underlying RoundTripper.
func (t *TelegramRedirectTransport) RoundTrip(request *http.Request) (*http.Response, error) {
if request.URL.Host != TelegramAPIHost {
return nil, fmt.Errorf("bots-host-gae: TelegramRedirectTransport rejected unexpected HTTP destination: %s", request.URL.Host)
}
clonedRequest := request.Clone(request.Context())
clonedURL := *request.URL
clonedURL.Scheme = t.baseURL.Scheme
clonedURL.Host = t.baseURL.Host
clonedRequest.URL = &clonedURL
return t.base.RoundTrip(clonedRequest)
}
161 changes: 161 additions & 0 deletions telegram_redirect_transport_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package gae

import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)

func TestNewTelegramRedirectTransport_nilBaseURL(t *testing.T) {
if _, err := NewTelegramRedirectTransport(nil, nil); err == nil {
t.Error("expected an error for a nil baseURL")
}
}

func TestNewTelegramRedirectTransport_incompleteBaseURL(t *testing.T) {
incomplete, err := url.Parse("/no-host-or-scheme")
if err != nil {
t.Fatalf("url.Parse: %v", err)
}
if _, err = NewTelegramRedirectTransport(incomplete, nil); err == nil {
t.Error("expected an error for a baseURL without a scheme and host")
}
}

func TestNewTelegramRedirectTransport_defaultsBaseTransport(t *testing.T) {
baseURL, err := url.Parse("http://127.0.0.1:1")
if err != nil {
t.Fatalf("url.Parse: %v", err)
}
transport, err := NewTelegramRedirectTransport(baseURL, nil)
if err != nil {
t.Fatalf("NewTelegramRedirectTransport: %v", err)
}
if transport.base == nil {
t.Error("expected a default base RoundTripper when base is nil")
}
}

func TestTelegramRedirectTransport_rejectsNonTelegramHost(t *testing.T) {
baseURL, err := url.Parse("http://127.0.0.1:1")
if err != nil {
t.Fatalf("url.Parse: %v", err)
}
transport, err := NewTelegramRedirectTransport(baseURL, http.DefaultTransport)
if err != nil {
t.Fatalf("NewTelegramRedirectTransport: %v", err)
}
request, err := http.NewRequest(http.MethodGet, "https://evil.example.com/steal", nil)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
if _, err = transport.RoundTrip(request); err == nil {
t.Fatal("expected RoundTrip to reject a non-Telegram destination")
}
}

func TestTelegramRedirectTransport_rewritesTelegramHost(t *testing.T) {
var gotPath, gotBody string
var served bool
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
served = true
gotPath = r.URL.Path
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer fake.Close()

baseURL, err := url.Parse(fake.URL)
if err != nil {
t.Fatalf("url.Parse: %v", err)
}
transport, err := NewTelegramRedirectTransport(baseURL, nil)
if err != nil {
t.Fatalf("NewTelegramRedirectTransport: %v", err)
}

request, err := http.NewRequest(
http.MethodPost,
"https://"+TelegramAPIHost+"/botTOKEN/sendMessage",
strings.NewReader(`{"chat_id":1}`),
)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
response, err := transport.RoundTrip(request)
if err != nil {
t.Fatalf("RoundTrip: %v", err)
}
defer func() { _ = response.Body.Close() }()

if response.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d", response.StatusCode, http.StatusOK)
}
if gotPath != "/botTOKEN/sendMessage" {
t.Errorf("path = %q, want /botTOKEN/sendMessage", gotPath)
}
if gotBody != `{"chat_id":1}` {
t.Errorf("body = %q, want the original request body", gotBody)
}
if !served {
t.Fatal("request never reached the fake server — it was not redirected")
}

// The original request must be left untouched (RoundTrip clones it).
if request.URL.Host != TelegramAPIHost {
t.Errorf("original request was mutated: Host = %q, want %q", request.URL.Host, TelegramAPIHost)
}
}

func TestBotHostWithHTTPClient_nilClientPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("expected BotHostWithHTTPClient(nil) to panic")
}
}()
BotHostWithHTTPClient(nil)
}

func TestBotHostWithHTTPClient_returnsSuppliedClient(t *testing.T) {
client := &http.Client{}
host := BotHostWithHTTPClient(client)
if got := host.GetHTTPClient(context.Background()); got != client {
t.Errorf("GetHTTPClient() = %p, want the supplied client %p", got, client)
}
}

func TestBotHostWithHTTPClient_GetHTTPClient_nilContextPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("expected GetHTTPClient(nil) to panic")
}
}()
host := BotHostWithHTTPClient(&http.Client{})
var ctx context.Context
host.GetHTTPClient(ctx)
}

func TestBotHostWithHTTPClient_Context(t *testing.T) {
host := BotHostWithHTTPClient(&http.Client{})
r := &http.Request{}
if ctx := host.Context(r); ctx == nil {
t.Error("Context() returns nil")
}
}

// TestBotHost_unaffectedByBotHostWithHTTPClient guards the non-breaking
// requirement: BotHost() must keep returning http.DefaultClient regardless of
// BotHostWithHTTPClient's existence.
func TestBotHost_unaffectedByBotHostWithHTTPClient(t *testing.T) {
_ = BotHostWithHTTPClient(&http.Client{})
host := BotHost()
if got := host.GetHTTPClient(context.Background()); got != http.DefaultClient {
t.Errorf("BotHost().GetHTTPClient() = %p, want http.DefaultClient %p", got, http.DefaultClient)
}
}