From 6eba256e3412b90d3c690151268efb6ea09d8d26 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Sun, 3 May 2026 07:52:50 +0700 Subject: [PATCH 1/3] [api] add RFC9727 support --- api/requests.http | 5 + internal/sms-gateway/handlers/3rdparty.go | 6 ++ internal/sms-gateway/handlers/api_catalog.go | 100 +++++++++++++++++++ internal/sms-gateway/handlers/module.go | 1 + 4 files changed, 112 insertions(+) create mode 100644 internal/sms-gateway/handlers/api_catalog.go diff --git a/api/requests.http b/api/requests.http index e9bff09e..c2bdd24d 100644 --- a/api/requests.http +++ b/api/requests.http @@ -289,3 +289,8 @@ GET http://localhost:3000/health/ready HTTP/1.1 ### GET http://localhost:3000/health/live HTTP/1.1 +### +HEAD http://localhost:3000/.well-known/api-catalog HTTP/1.1 + +### +GET http://localhost:3000/.well-known/api-catalog HTTP/1.1 diff --git a/internal/sms-gateway/handlers/3rdparty.go b/internal/sms-gateway/handlers/3rdparty.go index f918c7da..4d5bd20b 100644 --- a/internal/sms-gateway/handlers/3rdparty.go +++ b/internal/sms-gateway/handlers/3rdparty.go @@ -73,6 +73,12 @@ func newThirdPartyHandler( func (h *thirdPartyHandler) Register(router fiber.Router) { router = router.Group("/3rdparty/v1") + // Add Link header pointing to api-catalog (RFC 9727 Section 3) + router.Use(func(c *fiber.Ctx) error { + c.Set(fiber.HeaderLink, `; rel="api-catalog"`) + return c.Next() + }) + h.healthHandler.Register(router) router.Use( diff --git a/internal/sms-gateway/handlers/api_catalog.go b/internal/sms-gateway/handlers/api_catalog.go new file mode 100644 index 00000000..7659b0ae --- /dev/null +++ b/internal/sms-gateway/handlers/api_catalog.go @@ -0,0 +1,100 @@ +package handlers + +import ( + "fmt" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/limiter" + "go.uber.org/zap" +) + +type APICatalogHandler struct { + config Config + logger *zap.Logger +} + +func newAPICatalogHandler(cfg Config, logger *zap.Logger) *APICatalogHandler { + return &APICatalogHandler{ + config: cfg, + logger: logger.Named("api_catalog"), + } +} + +func (h *APICatalogHandler) get(c *fiber.Ctx) error { + const ( + fieldHref = "href" + fieldType = "type" + ) + + c.Set(fiber.HeaderLink, `; rel="api-catalog"`) + c.Set(fiber.HeaderCacheControl, "public, max-age=3600") + + host := h.getHost(c) + path := h.getPath() + base := fmt.Sprintf("https://%s", host) + if path != "" { + base = fmt.Sprintf("%s/%s", base, path) + } + + linkset := fiber.Map{ + "linkset": []fiber.Map{ + { + "anchor": fmt.Sprintf("%s/3rdparty/v1", base), + "service-desc": []fiber.Map{ + { + fieldHref: fmt.Sprintf("%s/docs/doc.json", base), + fieldType: "application/json", + }, + }, + "service-doc": []fiber.Map{ + { + fieldHref: "https://docs.sms-gate.app/", + fieldType: "text/html", + }, + }, + "status": []fiber.Map{ + { + fieldHref: fmt.Sprintf("%s/3rdparty/v1/health/ready", base), + fieldType: "application/json", + }, + }, + }, + }, + } + + return c.JSON(linkset, `application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"`) +} + +func (h *APICatalogHandler) head(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, `application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"`) + c.Set(fiber.HeaderLink, `; rel="api-catalog"`) + c.Set(fiber.HeaderCacheControl, "public, max-age=3600") + return c.SendStatus(fiber.StatusOK) +} + +func (h *APICatalogHandler) getHost(c *fiber.Ctx) string { + if h.config.PublicHost != "" { + return h.config.PublicHost + } + return c.Hostname() +} + +func (h *APICatalogHandler) getPath() string { + return strings.Trim(h.config.PublicPath, "/") +} + +func (h *APICatalogHandler) Register(app *fiber.App) { + const limit = 60 + + rateLimiter := limiter.New(limiter.Config{ + Max: limit, + Expiration: time.Minute, + LimiterMiddleware: limiter.SlidingWindow{}, + }) + + group := app.Group("/.well-known/api-catalog", rateLimiter) + group.Get("", h.get) + group.Head("", h.head) +} diff --git a/internal/sms-gateway/handlers/module.go b/internal/sms-gateway/handlers/module.go index 6c3ca086..66a1a6df 100644 --- a/internal/sms-gateway/handlers/module.go +++ b/internal/sms-gateway/handlers/module.go @@ -22,6 +22,7 @@ func Module() fx.Option { }), fx.Provide( http.AsRootHandler(newRootHandler), + http.AsRootHandler(newAPICatalogHandler), http.AsApiHandler(newThirdPartyHandler), http.AsApiHandler(newMobileHandler), http.AsApiHandler(newUpstreamHandler), From 438f0e8c80c35eb3c337a4d366f2746477513a78 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Mon, 25 May 2026 08:04:36 +0700 Subject: [PATCH 2/3] [deploy] pass `/.well-known` to the root --- api/requests.http | 4 ++-- deployments/docker-swarm-terraform/main.tf | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/api/requests.http b/api/requests.http index c2bdd24d..8d15a7a4 100644 --- a/api/requests.http +++ b/api/requests.http @@ -290,7 +290,7 @@ GET http://localhost:3000/health/ready HTTP/1.1 GET http://localhost:3000/health/live HTTP/1.1 ### -HEAD http://localhost:3000/.well-known/api-catalog HTTP/1.1 +HEAD {{baseUrl}}/.well-known/api-catalog HTTP/1.1 ### -GET http://localhost:3000/.well-known/api-catalog HTTP/1.1 +GET {{baseUrl}}/.well-known/api-catalog HTTP/1.1 diff --git a/deployments/docker-swarm-terraform/main.tf b/deployments/docker-swarm-terraform/main.tf index 822f7848..9b9fd2c9 100644 --- a/deployments/docker-swarm-terraform/main.tf +++ b/deployments/docker-swarm-terraform/main.tf @@ -159,6 +159,19 @@ resource "docker_service" "app" { label = "traefik.http.routers.${var.app-name}-new.tls.certresolver" value = "le" } + + labels { + label = "traefik.http.routers.${var.app-name}-wellknown.rule" + value = "Host(`api.sms-gate.app`) && PathPrefix(`/.well-known`) && Method(`GET`,`HEAD`)" + } + labels { + label = "traefik.http.routers.${var.app-name}-wellknown.entrypoints" + value = "https" + } + labels { + label = "traefik.http.routers.${var.app-name}-wellknown.tls.certresolver" + value = "le" + } #endregion #region Primary Limited From 38344d62dc3340eb98544426f3a7e3b655441a4d Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Mon, 25 May 2026 17:34:55 +0700 Subject: [PATCH 3/3] [actions] validate terraform w/o init --- .github/workflows/terraform-validate.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/terraform-validate.yml b/.github/workflows/terraform-validate.yml index b653a5b1..8c1460e1 100644 --- a/.github/workflows/terraform-validate.yml +++ b/.github/workflows/terraform-validate.yml @@ -14,9 +14,6 @@ jobs: name: Validate Terraform runs-on: ubuntu-latest if: github.actor != 'dependabot[bot]' - env: - AWS_ACCESS_KEY_ID: ${{secrets.AWS_ACCESS_KEY_ID}} - AWS_SECRET_ACCESS_KEY: ${{secrets.AWS_SECRET_ACCESS_KEY}} steps: - name: Checkout code uses: actions/checkout@v4 @@ -28,7 +25,7 @@ jobs: - name: Initialize Terraform working-directory: deployments/docker-swarm-terraform - run: terraform init + run: terraform init -backend=false - name: Validate Terraform working-directory: deployments/docker-swarm-terraform