Skip to content
Open
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
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260511-230358.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: Added an RSS feed for the API. Updates when adrscore, lifecycle, hash changed or oas unavailable.
time: 2026-05-11T23:03:58.751029+02:00
11 changes: 6 additions & 5 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ DB_USERNAME=don
DB_PASSWORD=don
DB_DBNAME=don_v1
DB_SCHEMA=public
TOOLS_API_ENDPOINT=https://api.don.apps.digilab.network/tools/v1
TOOLS_API_ENDPOINT=https://api.don.projects.digilab.network/tools/v1
X_API_KEY=123
ENABLE_TYPESENSE=true
TYPESENSE_ENDPOINT=https://search.don.apps.digilab.network
API_ENDPOINT=https://api-register.don.apps.digilab.network
ENABLE_TYPESENSE=false
Comment thread
pasibun marked this conversation as resolved.
TYPESENSE_ENDPOINT=https://search.don.projects.digilab.network
API_ENDPOINT=https://api-register.don.projects.digilab.network
TYPESENSE_API_KEY=123
TYPESENSE_COLLECTION=api_register
TYPESENSE_COLLECTION=api_register
PUBLIC_API_BASE_URL=https://api.don.projects.digilab.network/api-register/v1
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ Nieuwe APIs worden na een succesvolle POST ook naar Typesense gestuurd, zodat ze

Bij het opstarten van de server wordt automatisch een aparte service gestart die direct een refresh-run uitvoert. Daarna draait de job iedere ochtend om **07:00** en haalt alle geregistreerde APIs opnieuw op. Zodra de OAS is gewijzigd, volgen exact dezelfde stappen als bij een POST: validatie, regeneratie van artifacts (Bruno, Postman en OAS-bestanden) en het opruimen van verouderde bestanden. Er zijn geen extra omgevingsvariabelen nodig.

## RSS-feed configuratie

Elke API heeft een RSS-feed beschikbaar op `/apis/{id}/feed.rss`.

## Changelog (Changie)

Voor user-facing wijzigingen (fix/feature/breaking) verwachten we per PR een Changie-fragment in `.changes/unreleased`.
Expand Down Expand Up @@ -123,4 +127,4 @@ Een contribution of pull request leidt niet automatisch tot een deployment.
`[deploy-test]` in de commit message.
- Die testdeploy gebruikt repository- en organization-variables en secrets om
ook `INFRA_REPO` aan te passen. Daardoor is dit pad in de praktijk bedoeld
voor maintainers of contributors met een branch in deze repository.
voor maintainers of contributors met een branch in deze repository.
44 changes: 44 additions & 0 deletions api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,50 @@
}
}
},
"/apis/{id}/feed.rss": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Unique identifier of the resource.",
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Public endpoints",
"APIs"
],
"summary": "Get API RSS feed",
"description": "Returns an RSS 2.0 feed with content changes for a single API. Feed items are created for ADR-score changes, lifecycle changes, OpenAPI document hash changes, and OpenAPI documents that become unavailable during the daily refresh job.",
"operationId": "getApiFeed",
"responses": {
"200": {
"description": "OK",
"headers": {
"API-Version": {
"$ref": "#/components/headers/ApiVersion"
}
},
"content": {
"application/rss+xml": {
"schema": {
"type": "string",
"description": "RSS 2.0 XML feed."
},
"example": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\">\n <channel>\n <title>Wijzigingen voor Voorbeeld API</title>\n <link>https://apis.developer.overheid.nl/apis/api-1</link>\n <description>RSS feed met inhoudelijke wijzigingen voor Voorbeeld API.</description>\n <language>nl</language>\n </channel>\n</rss>"
}
}
},
"404": {
"$ref": "#/components/responses/404"
}
}
}
},
"/apis/{id}/postman": {
"parameters": [
{
Expand Down
29 changes: 15 additions & 14 deletions pkg/api_client/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,21 @@ import (
)

func Connect(connStr string) (*gorm.DB, error) {
db, err := gorm.Open(postgres.Open(connStr))
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
db, err := gorm.Open(postgres.Open(connStr))
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}

if err := db.AutoMigrate(
&models.Api{},
&models.LintResult{},
&models.LintMessage{},
&models.LintMessageInfo{},
&models.ApiArtifact{},
); err != nil {
return nil, fmt.Errorf("migration failed: %w", err)
}
if err := db.AutoMigrate(
&models.Api{},
&models.LintResult{},
&models.LintMessage{},
&models.LintMessageInfo{},
&models.ApiArtifact{},
&models.ApiFeedEvent{},
); err != nil {
return nil, fmt.Errorf("migration failed: %w", err)
}

return db, nil
return db, nil
}
14 changes: 14 additions & 0 deletions pkg/api_client/handler/api_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,20 @@ func (c *APIsAPIController) RetrieveApi(ctx *gin.Context, params *models.ApiPara
return api, nil
}

// GetApiFeed handles GET /apis/:id/feed.rss
func (c *APIsAPIController) GetApiFeed(ctx *gin.Context, params *models.ApiParams) error {
frontendURL := util.FrontendAPIURL(params.Id)
data, err := c.Service.GetApiFeed(ctx.Request.Context(), params.Id, frontendURL)
if err != nil {
return err
}
if data == nil {
return problem.NewNotFound(params.Id, "Api not found")
}
ctx.Data(200, "application/rss+xml; charset=utf-8", data)
return nil
}

// ListLintResults handles GET /lint-results
func (c *APIsAPIController) ListLintResults(ctx *gin.Context) ([]models.LintResult, error) {
return c.Service.ListLintResults(ctx.Request.Context())
Expand Down
55 changes: 55 additions & 0 deletions pkg/api_client/handler/api_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"

problem "github.com/developer-overheid-nl/don-api-register/pkg/api_client/helpers/problem"
"github.com/developer-overheid-nl/don-api-register/pkg/api_client/models"
Expand All @@ -27,6 +29,7 @@ type stubRepo struct {
saveOrg func(org *models.Organisation) error
getOasArt func(ctx context.Context, apiID, version, format string) (*models.ApiArtifact, error)
filterCounts func(ctx context.Context, p *models.ApiFiltersParams) (*models.ApiFilterCounts, error)
listFeed func(ctx context.Context, apiID string, limit int) ([]models.ApiFeedEvent, error)
}

func (s *stubRepo) GetApis(ctx context.Context, page, perPage int, p *models.ApiFiltersParams) ([]models.Api, models.Pagination, error) {
Expand Down Expand Up @@ -100,6 +103,15 @@ func (s *stubRepo) GetApiFilterCounts(ctx context.Context, p *models.ApiFiltersP
}
return &models.ApiFilterCounts{}, nil
}
func (s *stubRepo) SaveApiFeedEvent(ctx context.Context, event *models.ApiFeedEvent) error {
return nil
}
func (s *stubRepo) ListApiFeedEvents(ctx context.Context, apiID string, limit int) ([]models.ApiFeedEvent, error) {
if s.listFeed != nil {
return s.listFeed(ctx, apiID, limit)
}
return []models.ApiFeedEvent{}, nil
}

func TestGetOas_Handler(t *testing.T) {
repo := &stubRepo{
Expand Down Expand Up @@ -139,6 +151,49 @@ func TestGetOas_Handler(t *testing.T) {
assert.Equal(t, `{"openapi":"3.1.0"}`, w.Body.String())
}

func TestGetApiFeed_Handler(t *testing.T) {
createdAt := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
repo := &stubRepo{
retrFunc: func(ctx context.Context, id string) (*models.Api, error) {
assert.Equal(t, "api-1", id)
return &models.Api{Id: id, Title: "Demo API"}, nil
},
listFeed: func(ctx context.Context, apiID string, limit int) ([]models.ApiFeedEvent, error) {
assert.Equal(t, "api-1", apiID)
assert.Equal(t, 50, limit)
return []models.ApiFeedEvent{
{
ID: "event-1",
ApiID: apiID,
Type: models.ApiFeedEventLifecycleChanged,
Title: "Lifecycle gewijzigd",
Description: "Lifecycle wijzigde van active naar deprecated.",
CreatedAt: createdAt,
},
}, nil
},
}
svc := services.NewAPIsAPIService(repo)
ctrl := NewAPIsAPIController(svc)

w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
ctx.Request = httptest.NewRequest("GET", "/v1/apis/api-1/feed.rss", nil)

err := ctrl.GetApiFeed(ctx, &models.ApiParams{Id: "api-1"})
assert.NoError(t, err)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "application/rss+xml; charset=utf-8", w.Header().Get("Content-Type"))
body := w.Body.String()
assert.True(t, strings.HasPrefix(body, "<?xml"))
assert.Contains(t, body, `<rss version="2.0">`)
assert.NotContains(t, body, `xmlns:atom`)
assert.NotContains(t, body, `<atom:link`)
assert.Contains(t, body, "<link>https://apis.developer.overheid.nl/apis/api-1</link>")
assert.Contains(t, body, "<title>Wijzigingen voor Demo API</title>")
assert.Contains(t, body, "<guid isPermaLink=\"false\">event-1</guid>")
}

func TestGetOas_AllowsPatchVersion(t *testing.T) {
var capturedVersion, capturedFormat string
repo := &stubRepo{
Expand Down
93 changes: 93 additions & 0 deletions pkg/api_client/helpers/util/url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package util

import (
"fmt"
"net/http"
"strings"
)

const frontendAPIBaseURL = "https://apis.developer.overheid.nl/apis"

Comment thread
pasibun marked this conversation as resolved.
func FrontendAPIURL(id string) string {
return fmt.Sprintf("%s/%s", frontendAPIBaseURL, id)
}

func AbsoluteCurrentRequestURL(r *http.Request) string {
if r == nil || r.URL == nil {
return ""
}
if forwardedURI := strings.TrimSpace(r.Header.Get("X-Forwarded-Uri")); forwardedURI != "" {
return AbsoluteRequestURL(r, forwardedURI)
}
if originalURI := strings.TrimSpace(r.Header.Get("X-Original-URI")); originalURI != "" {
return AbsoluteRequestURL(r, originalURI)
}
path := r.URL.RequestURI()
if strings.TrimSpace(path) == "" {
path = r.URL.Path
}
return AbsoluteRequestURL(r, path)
}

func AbsoluteRequestURL(r *http.Request, path string) string {
if r == nil {
return ""
}
scheme := "https"
host := r.Host

if forwardedScheme, forwardedHost := parseForwardedHeader(r.Header.Get("Forwarded")); forwardedScheme != "" || forwardedHost != "" {
if forwardedScheme != "" {
scheme = forwardedScheme
}
if forwardedHost != "" {
host = forwardedHost
}
}
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
scheme = strings.Split(forwarded, ",")[0]
}
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); forwarded != "" {
host = strings.Split(forwarded, ",")[0]
}
if prefix := strings.TrimRight(strings.TrimSpace(r.Header.Get("X-Forwarded-Prefix")), "/"); prefix != "" && !strings.HasPrefix(path, prefix+"/") {
path = prefix + path
}
scheme, path = normalizePublicAPIURL(scheme, host, path)
return fmt.Sprintf("%s://%s%s", strings.TrimSpace(scheme), strings.TrimSpace(host), path)
Comment thread
pasibun marked this conversation as resolved.
}

func normalizePublicAPIURL(scheme, host, path string) (string, string) {
hostWithoutPort := strings.Split(strings.TrimSpace(host), ":")[0]
switch hostWithoutPort {
case "api.developer.overheid.nl", "api.don.projects.digilab.network":
scheme = "https"
if path == "/v1" || strings.HasPrefix(path, "/v1/") {
path = "/api-register" + path
}
}
return scheme, path
}

func parseForwardedHeader(value string) (string, string) {
first := strings.TrimSpace(strings.Split(value, ",")[0])
if first == "" {
return "", ""
}

var proto, host string
for _, part := range strings.Split(first, ";") {
key, val, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
val = strings.Trim(strings.TrimSpace(val), `"`)
switch strings.ToLower(strings.TrimSpace(key)) {
case "proto":
proto = val
case "host":
host = val
}
}
return proto, host
}
44 changes: 44 additions & 0 deletions pkg/api_client/helpers/util/url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package util

import (
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
)

func TestFrontendAPIURL(t *testing.T) {
assert.Equal(t, "https://apis.developer.overheid.nl/apis/api-1", FrontendAPIURL("api-1"))
}

func TestAbsoluteCurrentRequestURL_UsesForwardedLocation(t *testing.T) {
req := httptest.NewRequest("GET", "/internal/apis/api-1/feed?format=rss", nil)
req.Header.Set("Forwarded", `proto=https;host="api.example.test"`)
req.Header.Set("X-Forwarded-Uri", "/api-register/v1/apis/api-1/feed?format=rss")

assert.Equal(t,
"https://api.example.test/api-register/v1/apis/api-1/feed?format=rss",
AbsoluteCurrentRequestURL(req),
)
}

func TestAbsoluteCurrentRequestURL_NormalizesPublicAPIHost(t *testing.T) {
req := httptest.NewRequest("GET", "/v1/apis/MBDp9RTvg/feed", nil)
req.Host = "api.don.projects.digilab.network"
req.Header.Set("X-Forwarded-Proto", "http")

assert.Equal(t,
"https://api.don.projects.digilab.network/api-register/v1/apis/MBDp9RTvg/feed",
AbsoluteCurrentRequestURL(req),
)
}

func TestAbsoluteCurrentRequestURL_NormalizesProductionAPIHost(t *testing.T) {
req := httptest.NewRequest("GET", "/v1/apis/MBDp9RTvg/feed", nil)
req.Host = "api.developer.overheid.nl"

assert.Equal(t,
"https://api.developer.overheid.nl/api-register/v1/apis/MBDp9RTvg/feed",
AbsoluteCurrentRequestURL(req),
)
}
Loading
Loading