feat: implement History API service with Swagger documentation and routing
All checks were successful
Build and Release / release (push) Successful in 1m36s

This commit is contained in:
2026-05-18 19:29:10 +07:00
parent fc7320cda1
commit 65f73a70f1
8 changed files with 9357 additions and 8902 deletions

View File

@@ -0,0 +1,83 @@
package controllers
import (
"history-api/internal/services"
"strings"
"github.com/gofiber/fiber/v3"
)
type GoongController interface {
Proxy(c fiber.Ctx) error
}
type goongController struct {
goongService services.GoongService
}
func NewGoongController(goongService services.GoongService) GoongController {
return &goongController{
goongService: goongService,
}
}
// Proxy godoc
// @Summary Proxy Goong APIs
// @Description Transparent proxy for Goong APIs to forward body, params, headers and inject API key automatically.
// @Tags Goong
// @Accept json
// @Produce json
// @Produce application/x-protobuf
// @Param path path string true "Target URL to proxy, e.g., 'tiles.goong.io/assets/goong_map_web.json'"
// @Success 200 {string} string "Resource content"
// @Failure 400 {string} string "Bad Request"
// @Failure 403 {string} string "Forbidden"
// @Failure 500 {string} string "Internal Server Error"
// @Router /proxy/{path} [get]
func (ctrl *goongController) Proxy(c fiber.Ctx) error {
path := c.Params("*")
if path == "" {
return c.Status(fiber.StatusBadRequest).SendString("Invalid proxy URL format")
}
targetURL := path
if strings.HasPrefix(targetURL, "https:/") && !strings.HasPrefix(targetURL, "https://") {
targetURL = strings.Replace(targetURL, "https:/", "https://", 1)
} else if strings.HasPrefix(targetURL, "http:/") && !strings.HasPrefix(targetURL, "http://") {
targetURL = strings.Replace(targetURL, "http:/", "http://", 1)
}
if len(c.Request().URI().QueryString()) > 0 {
targetURL += "?" + string(c.Request().URI().QueryString())
}
headers := make(map[string]string)
for k, v := range c.GetReqHeaders() {
if len(v) > 0 {
headers[k] = v[0]
}
}
statusCode, respHeaders, respBody, err := ctrl.goongService.ProxyRequest(
c.Context(),
c.Method(),
targetURL,
headers,
c.Body(),
)
if err != nil {
return c.Status(statusCode).SendString(err.Error())
}
for k, v := range respHeaders {
c.Set(k, v)
}
if c.Method() == "GET" {
c.Set("Cache-Control", "public, max-age=86400")
}
return c.Status(statusCode).Send(respBody)
}

View File

@@ -0,0 +1,13 @@
package routes
import (
"history-api/internal/controllers"
"github.com/gofiber/fiber/v3"
)
func GoongRoutes(app *fiber.App, goongController controllers.GoongController) {
api := app.Group("/proxy")
api.Get("/*", goongController.Proxy)
}

View File

@@ -0,0 +1,114 @@
package services
import (
"bytes"
"context"
"fmt"
"history-api/pkg/cache"
"history-api/pkg/config"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type GoongService interface {
ProxyRequest(ctx context.Context, method string, targetURL string, headers map[string]string, reqBody []byte) (int, map[string]string, []byte, error)
}
type CacheEntry struct {
StatusCode int `json:"status_code"`
Headers map[string]string `json:"headers"`
Body []byte `json:"body"`
}
type goongService struct {
redis cache.Cache
}
func NewGoongService(redis cache.Cache) GoongService {
return &goongService{
redis: redis,
}
}
func (s *goongService) ProxyRequest(ctx context.Context, method string, targetURL string, headers map[string]string, reqBody []byte) (int, map[string]string, []byte, error) {
apiKey, err := config.GetConfig("GOONG_API_KEY")
if err != nil {
return 500, nil, nil, fmt.Errorf("GOONG_API_KEY is not configured")
}
fullTargetURL := targetURL
if !strings.HasPrefix(fullTargetURL, "http://") && !strings.HasPrefix(fullTargetURL, "https://") {
fullTargetURL = "https://" + fullTargetURL
}
parsedUrl, err := url.Parse(fullTargetURL)
if err != nil || parsedUrl.Host == "" {
return 400, nil, nil, fmt.Errorf("invalid target URL")
}
if !strings.HasSuffix(parsedUrl.Host, "goong.io") {
return 403, nil, nil, fmt.Errorf("only goong.io domains are allowed")
}
q := parsedUrl.Query()
q.Set("api_key", apiKey)
parsedUrl.RawQuery = q.Encode()
finalURL := parsedUrl.String()
cacheKey := fmt.Sprintf("goong_proxy:%s", finalURL)
if method == http.MethodGet {
var entry CacheEntry
if err := s.redis.Get(ctx, cacheKey, &entry); err == nil && len(entry.Body) > 0 {
return entry.StatusCode, entry.Headers, entry.Body, nil
}
}
req, err := http.NewRequestWithContext(ctx, method, finalURL, nil)
if err != nil {
return 500, nil, nil, fmt.Errorf("failed to create request: %w", err)
}
if len(reqBody) > 0 {
req.Body = io.NopCloser(bytes.NewReader(reqBody))
}
for k, v := range headers {
lowerK := strings.ToLower(k)
if lowerK == "host" || lowerK == "connection" || lowerK == "accept-encoding" {
continue
}
req.Header.Set(k, v)
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 500, nil, nil, fmt.Errorf("failed to fetch from target: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return 500, nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
respHeaders := make(map[string]string)
for k, v := range resp.Header {
respHeaders[k] = v[0]
}
if method == http.MethodGet && resp.StatusCode == http.StatusOK {
entry := CacheEntry{
StatusCode: resp.StatusCode,
Headers: respHeaders,
Body: respBody,
}
_ = s.redis.Set(ctx, cacheKey, entry, 24*time.Hour)
}
return resp.StatusCode, respHeaders, respBody, nil
}