From 14844ede7fc03a61aa0c644a6c3ae209e08b00ae Mon Sep 17 00:00:00 2001 From: Dimitri van Hees Date: Thu, 12 Feb 2026 18:09:41 +0100 Subject: [PATCH 01/15] wip --- adr-adoption-plan.md | 715 ++++++++++++++++++ cmd/main.go | 6 +- pkg/api_client/handler/statistics_handler.go | 37 + pkg/api_client/models/adoption.go | 106 +++ .../repositories/adoption_repository.go | 447 +++++++++++ pkg/api_client/routers.go | 45 +- pkg/api_client/services/adoption_service.go | 291 +++++++ 7 files changed, 1645 insertions(+), 2 deletions(-) create mode 100644 adr-adoption-plan.md create mode 100644 pkg/api_client/handler/statistics_handler.go create mode 100644 pkg/api_client/models/adoption.go create mode 100644 pkg/api_client/repositories/adoption_repository.go create mode 100644 pkg/api_client/services/adoption_service.go diff --git a/adr-adoption-plan.md b/adr-adoption-plan.md new file mode 100644 index 0000000..54804e4 --- /dev/null +++ b/adr-adoption-plan.md @@ -0,0 +1,715 @@ +# Implementatieplan: ADR Adoptie Statistieken API + +## 1. Context + +### 1.1 Doel +API endpoints voor het ontsluiten van ADR adoptiestatistieken in een dashboard. Het dashboard toont per ADR versie de adoptiegraad per regel over tijd, met diverse filtermogelijkheden. + +### 1.2 Database Schema +``` +lint_results +├── id (PK) +├── api_id (FK naar apis tabel) +├── successes (boolean) +├── failures (bigint) +├── warnings (bigint) +├── created_at (timestamptz) +└── adr_version (text) + +lint_messages +├── id (PK) +├── lint_result_id (FK) +├── line (bigint) +├── column (bigint) +├── severity (text) - "error" | "warning" +├── code (text) - bijv. "API-01", "API-05" +└── created_at (timestamptz) + +lint_message_infos +├── id (PK) +├── lint_message_id (FK) +├── message (text) +└── path (text) +``` + +### 1.3 Belangrijke Data-Eigenschap +Lint resultaten worden **alleen aangemaakt bij API wijzigingen**, niet periodiek. Voor tijdreeksen moet daarom de **laatst bekende status** per API worden gebruikt (point-in-time queries). + +### 1.4 Tabel- en Kolomnamen +GORM genereert tabelnamen automatisch (snake_case, meervoud) zonder `TableName()` overrides. De `search_path` is ingesteld op het schema, dus geen prefix nodig in queries. + +| Go field | DB kolom | Tabel | +|----------|----------|-------| +| `LintResult.Successes` | `successes` | `lint_results` | +| `LintResult.AdrVersion` | `adr_version` | `lint_results` | +| `Organisation.Label` | `label` | `organisations` | +| `Api.OrganisationID` | `organisation_id` | `apis` | + +--- + +## 2. API Specificatie + +### 2.1 Endpoints Overzicht + +| Endpoint | Methode | Doel | +|----------|---------|------| +| `/v1/statistics/summary` | GET | KPI's: totalen en overall adoptiegraad | +| `/v1/statistics/rules` | GET | Adoptie per ADR regel (snapshot) | +| `/v1/statistics/timeline` | GET | Adoptie over tijd per regel (voor grafieken) | +| `/v1/statistics/apis` | GET | Drill-down naar individuele API's | + +### 2.2 Gemeenschappelijke Query Parameters + +| Parameter | Type | Required | Beschrijving | +|-----------|------|----------|--------------| +| `adrVersion` | string | Ja | ADR versie (bijv. "1.0.0") | +| `startDate` | string (date) | Ja | Begin periode, ISO 8601 formaat (bijv. "2024-01-01") | +| `endDate` | string (date) | Ja | Eind periode, ISO 8601 formaat | +| `apiIds` | string | Nee | Comma-separated lijst van API ID's | +| `organisation` | string | Nee | Filter op organisatie URI | + +--- + +## 3. Endpoint Specificaties + +### 3.1 GET /v1/statistics/summary + +**Beschrijving**: Geeft algemene KPI's terug voor de geselecteerde periode. + +**Extra Parameters**: Geen + +**Response Model**: +```json +{ + "adrVersion": "1.0.0", + "period": { + "start": "2024-01-01", + "end": "2024-06-30" + }, + "totalApis": 150, + "compliantApis": 45, + "overallAdoptionRate": 30.0, + "totalLintRuns": 450 +} +``` + +**Velden**: +- `totalApis`: Aantal unieke API's met minimaal één lint result in of vóór de periode +- `compliantApis`: Aantal API's waarvan de laatst bekende status `successes = true` is +- `overallAdoptionRate`: `(compliantApis / totalApis) * 100`, afgerond op 1 decimaal +- `totalLintRuns`: Totaal aantal lint runs binnen de periode + +--- + +### 3.2 GET /v1/statistics/rules + +**Beschrijving**: Geeft adoptiegraad per ADR regel terug (snapshot op `endDate`). + +**Extra Parameters**: + +| Parameter | Type | Required | Beschrijving | +|-----------|------|----------|--------------| +| `ruleCodes` | string | Nee | Comma-separated regel codes (bijv. "API-01,API-05") | +| `severity` | string | Nee | Filter op severity: "error" of "warning" | + +**Response Model**: +```json +{ + "adrVersion": "1.0.0", + "period": { + "start": "2024-01-01", + "end": "2024-06-30" + }, + "totalApis": 150, + "rules": [ + { + "code": "API-01", + "severity": "error", + "violatingApis": 12, + "compliantApis": 138, + "adoptionRate": 92.0 + }, + { + "code": "API-05", + "severity": "error", + "violatingApis": 45, + "compliantApis": 105, + "adoptionRate": 70.0 + } + ] +} +``` + +**Berekening per regel**: +- Neem per API de laatst bekende lint result (op `endDate`) +- Tel voor die lint result of er een message is met `code = ` +- `violatingApis`: Aantal API's met minimaal één violation voor deze regel +- `compliantApis`: `totalApis - violatingApis` +- `adoptionRate`: `(compliantApis / totalApis) * 100` + +--- + +### 3.3 GET /v1/statistics/timeline + +**Beschrijving**: Geeft adoptie over tijd terug per regel voor grafieken. Altijd per regel: zonder `ruleCodes` worden alle regels teruggegeven, met `ruleCodes` alleen de opgegeven regels. + +**Extra Parameters**: + +| Parameter | Type | Required | Beschrijving | +|-----------|------|----------|--------------| +| `granularity` | string | Nee | "day", "week", of "month" (default: "month") | +| `ruleCodes` | string | Nee | Comma-separated regel codes (zonder = alle regels) | + +**Response Model**: +```json +{ + "adrVersion": "1.0.0", + "granularity": "month", + "series": [ + { + "type": "rule", + "ruleCode": "API-01", + "dataPoints": [ + { + "period": "2024-01", + "totalApis": 120, + "compliantApis": 100, + "adoptionRate": 83.3 + }, + { + "period": "2024-02", + "totalApis": 125, + "compliantApis": 110, + "adoptionRate": 88.0 + } + ] + }, + { + "type": "rule", + "ruleCode": "API-05", + "dataPoints": [ + { + "period": "2024-01", + "totalApis": 120, + "compliantApis": 60, + "adoptionRate": 50.0 + }, + { + "period": "2024-02", + "totalApis": 125, + "compliantApis": 70, + "adoptionRate": 56.0 + } + ] + } + ] +} +``` + +**Period formaat per granularity**: +- `day`: "2024-01-15" +- `week`: "2024-W03" (ISO week) +- `month`: "2024-01" + +--- + +### 3.4 GET /v1/statistics/apis + +**Beschrijving**: Lijst van API's met hun compliance status (voor drill-down in dashboard). + +**Extra Parameters**: + +| Parameter | Type | Required | Beschrijving | +|-----------|------|----------|--------------| +| `compliant` | boolean | Nee | Filter op compliance status | +| `ruleCodes` | string | Nee | Filter API's die deze specifieke regels schenden | +| `page` | integer | Nee | Pagina nummer (default: 1) | +| `perPage` | integer | Nee | Items per pagina (default: 20, max: 100) | + +**Response Model**: +```json +{ + "adrVersion": "1.0.0", + "period": { + "start": "2024-01-01", + "end": "2024-06-30" + }, + "apis": [ + { + "apiId": "abc-123", + "apiTitle": "Petstore API", + "organisation": "Gemeente Amsterdam", + "isCompliant": false, + "totalViolations": 3, + "totalWarnings": 5, + "violatedRules": ["API-01", "API-05", "API-12"], + "lastLintDate": "2024-06-28T14:30:00Z" + } + ] +} +``` + +**Response Headers** (bestaand patroon): +- `Total-Count`: Totaal aantal resultaten +- `Total-Pages`: Aantal pagina's +- `Per-Page`: Items per pagina +- `Current-Page`: Huidige pagina +- `Link`: RFC 5988 pagination links + +--- + +## 4. SQL Queries + +### 4.1 Point-in-Time Basis Query + +De kern van alle queries: de laatst bekende lint result per API op een bepaalde datum. + +```sql +-- Laatst bekende lint result per API op :end_date +WITH latest_results AS ( + SELECT DISTINCT ON (lr.api_id) + lr.id, + lr.api_id, + lr.successes, + lr.failures, + lr.warnings, + lr.created_at, + lr.adr_version + FROM lint_results lr + WHERE lr.created_at <= :end_date + AND lr.adr_version = :adr_version + -- Optionele filters: + -- AND lr.api_id IN (:api_ids) + -- AND lr.api_id IN (SELECT id FROM apis WHERE organisation_id = :organisation) + ORDER BY lr.api_id, lr.created_at DESC +) +SELECT * FROM latest_results; +``` + +### 4.2 Summary Query + +```sql +WITH latest_results AS ( + SELECT DISTINCT ON (lr.api_id) + lr.api_id, + lr.successes + FROM lint_results lr + WHERE lr.created_at <= :end_date + AND lr.adr_version = :adr_version + ORDER BY lr.api_id, lr.created_at DESC +) +SELECT + COUNT(*) AS total_apis, + COUNT(*) FILTER (WHERE successes = true) AS compliant_apis, + COALESCE(ROUND( + (COUNT(*) FILTER (WHERE successes = true)::numeric / NULLIF(COUNT(*), 0)) * 100, + 1 + ), 0) AS adoption_rate +FROM latest_results; + +-- Separaat: totaal aantal lint runs in de periode +SELECT COUNT(*) AS total_lint_runs +FROM lint_results +WHERE created_at BETWEEN :start_date AND :end_date + AND adr_version = :adr_version; +``` + +### 4.3 Rules Query + +```sql +WITH latest_results AS ( + SELECT DISTINCT ON (lr.api_id) + lr.id AS lint_result_id, + lr.api_id + FROM lint_results lr + WHERE lr.created_at <= :end_date + AND lr.adr_version = :adr_version + ORDER BY lr.api_id, lr.created_at DESC +), +total_count AS ( + SELECT COUNT(*) AS total_apis FROM latest_results +), +violations_per_rule AS ( + SELECT + lm.code, + lm.severity, + COUNT(DISTINCT lr.api_id) AS violating_apis + FROM latest_results lr + JOIN lint_messages lm ON lm.lint_result_id = lr.lint_result_id + WHERE 1=1 + -- Optioneel: AND lm.code IN (:rule_codes) + -- Optioneel: AND lm.severity = :severity + GROUP BY lm.code, lm.severity +) +SELECT + v.code, + v.severity, + v.violating_apis, + t.total_apis +FROM violations_per_rule v +CROSS JOIN total_count t +ORDER BY v.code; +``` + +### 4.4 Timeline Query (Per Regel) + +Altijd per regel. Zonder `ruleCodes` filter worden eerst alle regels opgehaald via een aparte query op de laatst bekende lint results. + +```sql +WITH date_series AS ( + SELECT generate_series( + date_trunc(:granularity, :start_date::date), + date_trunc(:granularity, :end_date::date), + ('1 ' || :granularity)::interval + )::date AS period_start +), +rules AS ( + SELECT unnest(ARRAY[:rule_codes]::text[]) AS code +), +period_rules AS ( + SELECT + ds.period_start, + r.code AS rule_code, + CASE :granularity + WHEN 'day' THEN ds.period_start + WHEN 'week' THEN ds.period_start + interval '6 days' + WHEN 'month' THEN (ds.period_start + interval '1 month' - interval '1 day')::date + END AS period_end + FROM date_series ds + CROSS JOIN rules r +) +SELECT + pr.rule_code, + TO_CHAR(pr.period_start, + CASE :granularity + WHEN 'day' THEN 'YYYY-MM-DD' + WHEN 'week' THEN 'IYYY-"W"IW' + WHEN 'month' THEN 'YYYY-MM' + END + ) AS period, + ( + SELECT COUNT(DISTINCT api_id) + FROM lint_results + WHERE created_at <= pr.period_end + AND adr_version = :adr_version + ) AS total_apis, + ( + SELECT COUNT(DISTINCT sub.api_id) + FROM ( + SELECT DISTINCT ON (api_id) id, api_id + FROM lint_results + WHERE created_at <= pr.period_end + AND adr_version = :adr_version + ORDER BY api_id, created_at DESC + ) sub + WHERE NOT EXISTS ( + SELECT 1 FROM lint_messages lm + WHERE lm.lint_result_id = sub.id + AND lm.code = pr.rule_code + ) + ) AS compliant_apis +FROM period_rules pr +ORDER BY pr.rule_code, pr.period_start; +``` + +### 4.5 APIs Query + +```sql +WITH latest_results AS ( + SELECT DISTINCT ON (lr.api_id) + lr.id AS lint_result_id, + lr.api_id, + lr.successes, + lr.failures, + lr.warnings, + lr.created_at + FROM lint_results lr + WHERE lr.created_at <= :end_date + AND lr.adr_version = :adr_version + ORDER BY lr.api_id, lr.created_at DESC +), +api_violations AS ( + SELECT + lr.api_id, + ARRAY_AGG(DISTINCT lm.code ORDER BY lm.code) AS violated_rules + FROM latest_results lr + JOIN lint_messages lm ON lm.lint_result_id = lr.lint_result_id + WHERE lm.severity = 'error' + GROUP BY lr.api_id +) +SELECT + lr.api_id, + a.title AS api_title, + COALESCE(o.label, '') AS organisation, + lr.successes AS is_compliant, + lr.failures AS total_violations, + lr.warnings AS total_warnings, + COALESCE(av.violated_rules, ARRAY[]::text[]) AS violated_rules, + lr.created_at AS last_lint_date +FROM latest_results lr +JOIN apis a ON a.id = lr.api_id +LEFT JOIN organisations o ON o.uri = a.organisation_id +LEFT JOIN api_violations av ON av.api_id = lr.api_id +-- Optionele filters: +-- WHERE lr.successes = :compliant +-- WHERE av.violated_rules && ARRAY[:rule_codes]::text[] +ORDER BY a.title +LIMIT :per_page +OFFSET (:page - 1) * :per_page; +``` + +--- + +## 5. Go Implementatie + +### 5.1 Bestandsstructuur + +``` +pkg/api_client/ +├── models/ +│ └── adoption.go # Response models + query param structs +├── repositories/ +│ └── adoption_repository.go # Database queries (raw SQL via db.Raw) +├── services/ +│ └── adoption_service.go # Business logic, date parsing, response assembly +├── handler/ +│ └── statistics_handler.go # HTTP handlers (StatisticsController) +└── routers.go # Route registratie (wijzigen) +``` + +### 5.2 Models + Params (models/adoption.go) + +Params zitten in het `models` package (bestaand patroon, zie `list_apis_params.go`). + +```go +package models + +import "time" + +// Gemeenschappelijk +type Period struct { + Start string `json:"start"` + End string `json:"end"` +} + +// Summary endpoint +type AdoptionSummary struct { + AdrVersion string `json:"adrVersion"` + Period Period `json:"period"` + TotalApis int `json:"totalApis"` + CompliantApis int `json:"compliantApis"` + OverallAdoptionRate float64 `json:"overallAdoptionRate"` + TotalLintRuns int `json:"totalLintRuns"` +} + +// Rules endpoint +type AdoptionRules struct { + AdrVersion string `json:"adrVersion"` + Period Period `json:"period"` + TotalApis int `json:"totalApis"` + Rules []RuleAdoption `json:"rules"` +} + +type RuleAdoption struct { + Code string `json:"code"` + Severity string `json:"severity"` + ViolatingApis int `json:"violatingApis"` + CompliantApis int `json:"compliantApis"` + AdoptionRate float64 `json:"adoptionRate"` +} + +// Timeline endpoint +type AdoptionTimeline struct { + AdrVersion string `json:"adrVersion"` + Granularity string `json:"granularity"` + Series []TimelineSeries `json:"series"` +} + +type TimelineSeries struct { + Type string `json:"type"` + RuleCode string `json:"ruleCode,omitempty"` + DataPoints []TimelinePoint `json:"dataPoints"` +} + +type TimelinePoint struct { + Period string `json:"period"` + TotalApis int `json:"totalApis"` + CompliantApis int `json:"compliantApis"` + AdoptionRate float64 `json:"adoptionRate"` +} + +// APIs endpoint +type AdoptionApis struct { + AdrVersion string `json:"adrVersion"` + Period Period `json:"period"` + Apis []ApiAdoption `json:"apis"` +} + +type ApiAdoption struct { + ApiId string `json:"apiId"` + ApiTitle string `json:"apiTitle"` + Organisation string `json:"organisation"` + IsCompliant bool `json:"isCompliant"` + TotalViolations int `json:"totalViolations"` + TotalWarnings int `json:"totalWarnings"` + ViolatedRules []string `json:"violatedRules"` + LastLintDate time.Time `json:"lastLintDate"` +} + +// Query parameter structs +type AdoptionBaseParams struct { + AdrVersion string `query:"adrVersion" binding:"required"` + StartDate string `query:"startDate" binding:"required"` + EndDate string `query:"endDate" binding:"required"` + ApiIds *string `query:"apiIds"` + Organisation *string `query:"organisation"` +} + +type AdoptionRulesParams struct { + AdoptionBaseParams + RuleCodes *string `query:"ruleCodes"` + Severity *string `query:"severity"` +} + +type AdoptionTimelineParams struct { + AdoptionBaseParams + Granularity string `query:"granularity"` + RuleCodes *string `query:"ruleCodes"` +} + +type AdoptionApisParams struct { + AdoptionBaseParams + Compliant *bool `query:"compliant"` + RuleCodes *string `query:"ruleCodes"` + Page int `query:"page"` + PerPage int `query:"perPage"` +} +``` + +### 5.3 Repository (repositories/adoption_repository.go) + +Gebruikt `db.Raw()` met parameterized queries. Geeft ruwe resultaat-structs terug (geen response models). + +```go +type AdoptionRepository interface { + GetSummary(ctx context.Context, params AdoptionQueryParams) (SummaryResult, int, error) + GetRules(ctx context.Context, params AdoptionQueryParams) ([]RuleRow, int, error) + GetTimeline(ctx context.Context, params TimelineQueryParams) ([]TimelineRow, error) + GetApis(ctx context.Context, params ApisQueryParams) ([]ApiRow, int, error) +} +``` + +Dynamische SQL-opbouw: filters (`apiIds`, `organisation`, `ruleCodes`, `severity`, `compliant`) worden conditioneel toegepast via string builder + args slice. + +### 5.4 Service (services/adoption_service.go) + +```go +type AdoptionService struct { repo AdoptionRepository } +``` + +Verantwoordelijkheden: +- Parse date strings (`time.Parse("2006-01-02", ...)`) +- Split comma-separated params +- Validate granularity (default "month") +- Validate page/perPage defaults +- Repo aanroepen, response models assembleren +- `adoptionRate` berekenen: `math.Round(float64(compliant)/float64(total)*1000) / 10` + +### 5.5 Handler (handler/statistics_handler.go) + +```go +type StatisticsController struct { Service *services.AdoptionService } +``` + +Methodes volgen bestaand patroon (zie `handler/api_handler.go`): +- `GetSummary(ctx *gin.Context, p *models.AdoptionBaseParams) (*models.AdoptionSummary, error)` +- `GetRules(ctx *gin.Context, p *models.AdoptionRulesParams) (*models.AdoptionRules, error)` +- `GetTimeline(ctx *gin.Context, p *models.AdoptionTimelineParams) (*models.AdoptionTimeline, error)` +- `GetApis(ctx *gin.Context, p *models.AdoptionApisParams) (*models.AdoptionApis, error)` — zet pagination headers via `util.SetPaginationHeaders()` + +### 5.6 Route Registratie (routers.go wijzigen) + +`NewRouter` krijgt extra parameter `statsController *handler.StatisticsController`. + +```go +statsGroup := f.Group("/v1/statistics", "Statistics", "ADR adoption statistics endpoints") + +statsGroup.GET("/summary", + []fizz.OperationOption{ + fizz.ID("getAdoptionSummary"), + fizz.Summary("Get adoption summary"), + fizz.Description("Returns overall adoption KPIs for the selected period"), + apiVersionHeaderOption, + badRequestResponse, + }, + tonic.Handler(statsController.GetSummary, 200), +) +// ... analoog voor /rules, /timeline, /apis +``` + +### 5.7 DI Wiring (cmd/main.go wijzigen) + +```go +adoptionRepo := repositories.NewAdoptionRepository(db) +adoptionService := services.NewAdoptionService(adoptionRepo) +statsController := handler.NewStatisticsController(adoptionService) + +router := api.NewRouter(version, APIsAPIController, statsController) +``` + +--- + +## 6. Aandachtspunten + +### 6.1 Performance Overwegingen +- De timeline queries met subqueries per periode kunnen zwaar zijn bij grote datasets +- Overweeg een index op `(adr_version, api_id, created_at DESC)` voor de `lint_results` tabel +- Bij performance problemen: overweeg materialized views of een snapshot tabel + +--- + +## 7. Testen + +### 7.1 Unit Tests +- Test repository methodes met mock database +- Test berekening van adoptieRate (edge cases: 0 API's, 100% compliant, etc.) + +### 7.2 Integratie Tests +- Test point-in-time logica: API gevalideerd op dag X moet meetellen op dag X+n +- Test filters werken correct in combinatie +- Test pagination headers + +### 7.3 Handmatig Testen +```bash +# Summary +curl "http://localhost:1337/v1/statistics/summary?adrVersion=1.0.0&startDate=2024-01-01&endDate=2024-06-30" + +# Rules +curl "http://localhost:1337/v1/statistics/rules?adrVersion=1.0.0&startDate=2024-01-01&endDate=2024-06-30&severity=error" + +# Timeline (alle regels) +curl "http://localhost:1337/v1/statistics/timeline?adrVersion=1.0.0&startDate=2024-01-01&endDate=2024-06-30&granularity=month" + +# Timeline (specifieke regels) +curl "http://localhost:1337/v1/statistics/timeline?adrVersion=1.0.0&startDate=2024-01-01&endDate=2024-06-30&granularity=month&ruleCodes=API-01,API-05" + +# APIs +curl "http://localhost:1337/v1/statistics/apis?adrVersion=1.0.0&startDate=2024-01-01&endDate=2024-06-30&compliant=false&page=1&perPage=20" +``` + +--- + +## 8. Checklist + +- [x] `adr_version` veld toevoegen aan `LintResult` model +- [x] `models/adoption.go` aanmaken met response structs + param structs +- [x] `repositories/adoption_repository.go` aanmaken met queries +- [x] `services/adoption_service.go` aanmaken +- [x] `handler/statistics_handler.go` aanmaken +- [x] Routes registreren in `routers.go` +- [x] DI wiring in `cmd/main.go` +- [ ] `go build ./...` succesvol +- [ ] Database index toevoegen voor performance +- [ ] Unit tests schrijven +- [ ] Integratie tests schrijven +- [ ] OpenAPI spec genereren en valideren diff --git a/cmd/main.go b/cmd/main.go index 857043b..c5ddf8d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -133,6 +133,10 @@ func main() { apiRepo := repositories.NewApiRepository(db) APIsAPIService := services.NewAPIsAPIService(apiRepo) APIsAPIController := handler.NewAPIsAPIController(APIsAPIService) + + adoptionRepo := repositories.NewAdoptionRepository(db) + adoptionService := services.NewAdoptionService(adoptionRepo) + statsController := handler.NewStatisticsController(adoptionService) if err := APIsAPIService.PublishAllApisToTypesense(context.Background()); err != nil { log.Fatalf("[typesense-sync] bulk publish failed: %v", err) } @@ -145,7 +149,7 @@ func main() { }() // Start server - router := api.NewRouter(version, APIsAPIController) + router := api.NewRouter(version, APIsAPIController, statsController) log.Println("Server is running on port 1337") log.Fatal(http.ListenAndServe(":1337", router)) diff --git a/pkg/api_client/handler/statistics_handler.go b/pkg/api_client/handler/statistics_handler.go new file mode 100644 index 0000000..68ba8f4 --- /dev/null +++ b/pkg/api_client/handler/statistics_handler.go @@ -0,0 +1,37 @@ +package handler + +import ( + "github.com/developer-overheid-nl/don-api-register/pkg/api_client/helpers/util" + "github.com/developer-overheid-nl/don-api-register/pkg/api_client/models" + "github.com/developer-overheid-nl/don-api-register/pkg/api_client/services" + "github.com/gin-gonic/gin" +) + +type StatisticsController struct { + Service *services.AdoptionService +} + +func NewStatisticsController(s *services.AdoptionService) *StatisticsController { + return &StatisticsController{Service: s} +} + +func (c *StatisticsController) GetSummary(ctx *gin.Context, p *models.AdoptionBaseParams) (*models.AdoptionSummary, error) { + return c.Service.GetSummary(ctx.Request.Context(), p) +} + +func (c *StatisticsController) GetRules(ctx *gin.Context, p *models.AdoptionRulesParams) (*models.AdoptionRules, error) { + return c.Service.GetRules(ctx.Request.Context(), p) +} + +func (c *StatisticsController) GetTimeline(ctx *gin.Context, p *models.AdoptionTimelineParams) (*models.AdoptionTimeline, error) { + return c.Service.GetTimeline(ctx.Request.Context(), p) +} + +func (c *StatisticsController) GetApis(ctx *gin.Context, p *models.AdoptionApisParams) (*models.AdoptionApis, error) { + result, pagination, err := c.Service.GetApis(ctx.Request.Context(), p) + if err != nil { + return nil, err + } + util.SetPaginationHeaders(ctx.Request, ctx.Header, *pagination) + return result, nil +} diff --git a/pkg/api_client/models/adoption.go b/pkg/api_client/models/adoption.go new file mode 100644 index 0000000..c7e9a4f --- /dev/null +++ b/pkg/api_client/models/adoption.go @@ -0,0 +1,106 @@ +package models + +import "time" + +type Period struct { + Start string `json:"start"` + End string `json:"end"` +} + +// Summary endpoint + +type AdoptionSummary struct { + AdrVersion string `json:"adrVersion"` + Period Period `json:"period"` + TotalApis int `json:"totalApis"` + CompliantApis int `json:"compliantApis"` + OverallAdoptionRate float64 `json:"overallAdoptionRate"` + TotalLintRuns int `json:"totalLintRuns"` +} + +// Rules endpoint + +type AdoptionRules struct { + AdrVersion string `json:"adrVersion"` + Period Period `json:"period"` + TotalApis int `json:"totalApis"` + Rules []RuleAdoption `json:"rules"` +} + +type RuleAdoption struct { + Code string `json:"code"` + Severity string `json:"severity"` + ViolatingApis int `json:"violatingApis"` + CompliantApis int `json:"compliantApis"` + AdoptionRate float64 `json:"adoptionRate"` +} + +// Timeline endpoint + +type AdoptionTimeline struct { + AdrVersion string `json:"adrVersion"` + Granularity string `json:"granularity"` + Series []TimelineSeries `json:"series"` +} + +type TimelineSeries struct { + Type string `json:"type"` + RuleCode string `json:"ruleCode,omitempty"` + DataPoints []TimelinePoint `json:"dataPoints"` +} + +type TimelinePoint struct { + Period string `json:"period"` + TotalApis int `json:"totalApis"` + CompliantApis int `json:"compliantApis"` + AdoptionRate float64 `json:"adoptionRate"` +} + +// APIs endpoint + +type AdoptionApis struct { + AdrVersion string `json:"adrVersion"` + Period Period `json:"period"` + Apis []ApiAdoption `json:"apis"` +} + +type ApiAdoption struct { + ApiId string `json:"apiId"` + ApiTitle string `json:"apiTitle"` + Organisation string `json:"organisation"` + IsCompliant bool `json:"isCompliant"` + TotalViolations int `json:"totalViolations"` + TotalWarnings int `json:"totalWarnings"` + ViolatedRules []string `json:"violatedRules"` + LastLintDate time.Time `json:"lastLintDate"` +} + +// Query parameter structs + +type AdoptionBaseParams struct { + AdrVersion string `query:"adrVersion" binding:"required"` + StartDate string `query:"startDate" binding:"required"` + EndDate string `query:"endDate" binding:"required"` + ApiIds *string `query:"apiIds"` + Organisation *string `query:"organisation"` +} + +type AdoptionRulesParams struct { + AdoptionBaseParams + RuleCodes *string `query:"ruleCodes"` + Severity *string `query:"severity"` +} + +type AdoptionTimelineParams struct { + AdoptionBaseParams + Granularity string `query:"granularity"` + RuleCodes *string `query:"ruleCodes"` +} + +type AdoptionApisParams struct { + AdoptionBaseParams + Compliant *bool `query:"compliant"` + RuleCodes *string `query:"ruleCodes"` + Page int `query:"page"` + PerPage int `query:"perPage"` +} diff --git a/pkg/api_client/repositories/adoption_repository.go b/pkg/api_client/repositories/adoption_repository.go new file mode 100644 index 0000000..31be11d --- /dev/null +++ b/pkg/api_client/repositories/adoption_repository.go @@ -0,0 +1,447 @@ +package repositories + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/lib/pq" + "gorm.io/gorm" +) + +// AdoptionRepository provides methods for querying ADR adoption statistics. +type AdoptionRepository interface { + GetSummary(ctx context.Context, params AdoptionQueryParams) (SummaryResult, int, error) + GetRules(ctx context.Context, params AdoptionQueryParams) ([]RuleRow, int, error) + GetTimeline(ctx context.Context, params TimelineQueryParams) ([]TimelineRow, error) + GetApis(ctx context.Context, params ApisQueryParams) ([]ApiRow, int, error) +} + +type AdoptionQueryParams struct { + AdrVersion string + StartDate time.Time + EndDate time.Time + ApiIds []string + Organisation *string + RuleCodes []string + Severity *string +} + +type TimelineQueryParams struct { + AdoptionQueryParams + Granularity string +} + +type ApisQueryParams struct { + AdoptionQueryParams + Compliant *bool + Page int + PerPage int +} + +type SummaryResult struct { + TotalApis int + CompliantApis int + AdoptionRate float64 +} + +type RuleRow struct { + Code string + Severity string + ViolatingApis int + TotalApis int +} + +type TimelineRow struct { + RuleCode string + Period string + TotalApis int + CompliantApis int +} + +type ApiRow struct { + ApiId string + ApiTitle string + Organisation string + IsCompliant bool + TotalFailures int + TotalWarnings int + ViolatedRules pq.StringArray `gorm:"type:text[]"` + LastLintDate time.Time +} + +type adoptionRepository struct { + db *gorm.DB +} + +func NewAdoptionRepository(db *gorm.DB) AdoptionRepository { + return &adoptionRepository{db: db} +} + +// latestResultsCTE builds the common CTE that finds the most recent lint result +// per API on or before endDate for a given adrVersion, with optional filters. +func latestResultsCTE(params AdoptionQueryParams, selectCols string) (string, []interface{}) { + var args []interface{} + + where := "lr.created_at <= ? AND lr.adr_version = ?" + args = append(args, params.EndDate, params.AdrVersion) + + if len(params.ApiIds) > 0 { + placeholders := make([]string, len(params.ApiIds)) + for i, id := range params.ApiIds { + placeholders[i] = "?" + args = append(args, id) + } + where += " AND lr.api_id IN (" + strings.Join(placeholders, ",") + ")" + } + + if params.Organisation != nil && strings.TrimSpace(*params.Organisation) != "" { + where += " AND lr.api_id IN (SELECT id FROM apis WHERE organisation_id = ?)" + args = append(args, strings.TrimSpace(*params.Organisation)) + } + + cte := fmt.Sprintf(`WITH latest_results AS ( + SELECT DISTINCT ON (lr.api_id) %s + FROM lint_results lr + WHERE %s + ORDER BY lr.api_id, lr.created_at DESC +)`, selectCols, where) + + return cte, args +} + +func (r *adoptionRepository) GetSummary(ctx context.Context, params AdoptionQueryParams) (SummaryResult, int, error) { + cte, args := latestResultsCTE(params, "lr.api_id, lr.successes") + + query := cte + ` +SELECT + COUNT(*) AS total_apis, + COUNT(*) FILTER (WHERE successes = true) AS compliant_apis, + COALESCE(ROUND( + (COUNT(*) FILTER (WHERE successes = true)::numeric / NULLIF(COUNT(*), 0)) * 100, + 1 + ), 0) AS adoption_rate +FROM latest_results` + + var result SummaryResult + if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&result).Error; err != nil { + return SummaryResult{}, 0, fmt.Errorf("summary query failed: %w", err) + } + + // Count total lint runs in the period + runsQuery := `SELECT COUNT(*) FROM lint_results WHERE created_at BETWEEN ? AND ? AND adr_version = ?` + runsArgs := []interface{}{params.StartDate, params.EndDate, params.AdrVersion} + + if len(params.ApiIds) > 0 { + placeholders := make([]string, len(params.ApiIds)) + for i, id := range params.ApiIds { + placeholders[i] = "?" + runsArgs = append(runsArgs, id) + } + runsQuery += " AND api_id IN (" + strings.Join(placeholders, ",") + ")" + } + if params.Organisation != nil && strings.TrimSpace(*params.Organisation) != "" { + runsQuery += " AND api_id IN (SELECT id FROM apis WHERE organisation_id = ?)" + runsArgs = append(runsArgs, strings.TrimSpace(*params.Organisation)) + } + + var totalRuns int + if err := r.db.WithContext(ctx).Raw(runsQuery, runsArgs...).Scan(&totalRuns).Error; err != nil { + return SummaryResult{}, 0, fmt.Errorf("lint runs count query failed: %w", err) + } + + return result, totalRuns, nil +} + +func (r *adoptionRepository) GetRules(ctx context.Context, params AdoptionQueryParams) ([]RuleRow, int, error) { + cte, args := latestResultsCTE(params, "lr.id AS lint_result_id, lr.api_id") + + // Build optional WHERE filters for violations + violationsWhere := "" + if len(params.RuleCodes) > 0 { + placeholders := make([]string, len(params.RuleCodes)) + for i, code := range params.RuleCodes { + placeholders[i] = "?" + args = append(args, code) + } + violationsWhere += " AND lm.code IN (" + strings.Join(placeholders, ",") + ")" + } + if params.Severity != nil && strings.TrimSpace(*params.Severity) != "" { + violationsWhere += " AND lm.severity = ?" + args = append(args, strings.TrimSpace(*params.Severity)) + } + + // The total_apis is included in each row via CROSS JOIN + query := cte + fmt.Sprintf(`, +total_count AS ( + SELECT COUNT(*) AS total_apis FROM latest_results +), +violations_per_rule AS ( + SELECT + lm.code, + lm.severity, + COUNT(DISTINCT lr.api_id) AS violating_apis + FROM latest_results lr + JOIN lint_messages lm ON lm.lint_result_id = lr.lint_result_id + WHERE 1=1%s + GROUP BY lm.code, lm.severity +) +SELECT + v.code, + v.severity, + v.violating_apis, + t.total_apis +FROM violations_per_rule v +CROSS JOIN total_count t +ORDER BY v.code`, violationsWhere) + + var rows []RuleRow + if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { + return nil, 0, fmt.Errorf("rules query failed: %w", err) + } + + // Extract totalApis from first row if available, otherwise query separately + totalApis := 0 + if len(rows) > 0 { + totalApis = rows[0].TotalApis + } else { + // No violations found, but we still need total count + baseCTE, baseArgs := latestResultsCTE(params, "lr.api_id") + countQuery := baseCTE + ` SELECT COUNT(*) FROM latest_results` + if err := r.db.WithContext(ctx).Raw(countQuery, baseArgs...).Scan(&totalApis).Error; err != nil { + return nil, 0, fmt.Errorf("total apis count failed: %w", err) + } + } + + return rows, totalApis, nil +} + +func (r *adoptionRepository) GetTimeline(ctx context.Context, params TimelineQueryParams) ([]TimelineRow, error) { + // Determine which rule codes to use + ruleCodes := params.RuleCodes + if len(ruleCodes) == 0 { + codes, err := r.getAllRuleCodes(ctx, params.AdoptionQueryParams) + if err != nil { + return nil, err + } + ruleCodes = codes + } + + if len(ruleCodes) == 0 { + return nil, nil + } + + // Validate granularity (used as literal in SQL, so must be whitelisted) + granularity := params.Granularity + if granularity != "day" && granularity != "week" && granularity != "month" { + granularity = "month" + } + + periodFormat := map[string]string{ + "day": "YYYY-MM-DD", + "week": `IYYY-"W"IW`, + "month": "YYYY-MM", + } + + periodEndExpr := map[string]string{ + "day": "ds.period_start", + "week": "ds.period_start + interval '6 days'", + "month": "(ds.period_start + interval '1 month' - interval '1 day')::date", + } + + // Build base filter for lint_results subqueries + baseWhere := "adr_version = ?" + var baseArgs []interface{} + baseArgs = append(baseArgs, params.AdrVersion) + + if len(params.ApiIds) > 0 { + placeholders := make([]string, len(params.ApiIds)) + for i, id := range params.ApiIds { + placeholders[i] = "?" + baseArgs = append(baseArgs, id) + } + baseWhere += " AND api_id IN (" + strings.Join(placeholders, ",") + ")" + } + if params.Organisation != nil && strings.TrimSpace(*params.Organisation) != "" { + baseWhere += " AND api_id IN (SELECT id FROM apis WHERE organisation_id = ?)" + baseArgs = append(baseArgs, strings.TrimSpace(*params.Organisation)) + } + + // Build rules placeholders + ruleCodePlaceholders := make([]string, len(ruleCodes)) + var ruleArgs []interface{} + for i, code := range ruleCodes { + ruleCodePlaceholders[i] = "?" + ruleArgs = append(ruleArgs, code) + } + + query := fmt.Sprintf(`WITH date_series AS ( + SELECT generate_series( + date_trunc('%s', ?::date), + date_trunc('%s', ?::date), + '1 %s'::interval + )::date AS period_start +), +rules AS ( + SELECT unnest(ARRAY[%s]::text[]) AS code +), +period_rules AS ( + SELECT + ds.period_start, + r.code AS rule_code, + %s AS period_end + FROM date_series ds + CROSS JOIN rules r +) +SELECT + pr.rule_code, + TO_CHAR(pr.period_start, '%s') AS period, + ( + SELECT COUNT(DISTINCT api_id) + FROM lint_results + WHERE created_at <= pr.period_end AND %s + ) AS total_apis, + ( + SELECT COUNT(DISTINCT sub.api_id) + FROM ( + SELECT DISTINCT ON (api_id) id, api_id + FROM lint_results + WHERE created_at <= pr.period_end AND %s + ORDER BY api_id, created_at DESC + ) sub + WHERE NOT EXISTS ( + SELECT 1 FROM lint_messages lm + WHERE lm.lint_result_id = sub.id + AND lm.code = pr.rule_code + ) + ) AS compliant_apis +FROM period_rules pr +ORDER BY pr.rule_code, pr.period_start`, + granularity, granularity, granularity, + strings.Join(ruleCodePlaceholders, ","), + periodEndExpr[granularity], + periodFormat[granularity], + baseWhere, + baseWhere, + ) + + // Args order: startDate, endDate, ruleArgs, baseArgs (total_apis), baseArgs (compliant_apis) + var args []interface{} + args = append(args, params.StartDate, params.EndDate) + args = append(args, ruleArgs...) + args = append(args, baseArgs...) + args = append(args, baseArgs...) + + var rows []TimelineRow + if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("timeline query failed: %w", err) + } + + return rows, nil +} + +func (r *adoptionRepository) getAllRuleCodes(ctx context.Context, params AdoptionQueryParams) ([]string, error) { + cte, args := latestResultsCTE(params, "lr.id AS lint_result_id, lr.api_id") + + query := cte + ` +SELECT DISTINCT lm.code +FROM latest_results lr +JOIN lint_messages lm ON lm.lint_result_id = lr.lint_result_id +ORDER BY lm.code` + + var codes []string + if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&codes).Error; err != nil { + return nil, fmt.Errorf("rule codes query failed: %w", err) + } + return codes, nil +} + +func (r *adoptionRepository) GetApis(ctx context.Context, params ApisQueryParams) ([]ApiRow, int, error) { + selectCols := "lr.id AS lint_result_id, lr.api_id, lr.successes, lr.failures, lr.warnings, lr.created_at" + cte, cteArgs := latestResultsCTE(params.AdoptionQueryParams, selectCols) + + baseQuery := cte + `, +api_violations AS ( + SELECT + lr.api_id, + ARRAY_AGG(DISTINCT lm.code ORDER BY lm.code) AS violated_rules + FROM latest_results lr + JOIN lint_messages lm ON lm.lint_result_id = lr.lint_result_id + WHERE lm.severity = 'error' + GROUP BY lr.api_id +) +SELECT + lr.api_id, + a.title AS api_title, + COALESCE(o.label, '') AS organisation, + lr.successes AS is_compliant, + lr.failures AS total_failures, + lr.warnings AS total_warnings, + COALESCE(av.violated_rules, ARRAY[]::text[]) AS violated_rules, + lr.created_at AS last_lint_date +FROM latest_results lr +JOIN apis a ON a.id = lr.api_id +LEFT JOIN organisations o ON o.uri = a.organisation_id +LEFT JOIN api_violations av ON av.api_id = lr.api_id` + + // Build optional WHERE clauses + var whereClauses []string + var filterArgs []interface{} + if params.Compliant != nil { + whereClauses = append(whereClauses, "lr.successes = ?") + filterArgs = append(filterArgs, *params.Compliant) + } + if len(params.RuleCodes) > 0 { + placeholders := make([]string, len(params.RuleCodes)) + for i, code := range params.RuleCodes { + placeholders[i] = "?" + filterArgs = append(filterArgs, code) + } + whereClauses = append(whereClauses, "av.violated_rules && ARRAY["+strings.Join(placeholders, ",")+"]::text[]") + } + + whereClause := "" + if len(whereClauses) > 0 { + whereClause = " WHERE " + strings.Join(whereClauses, " AND ") + } + + // Count query: use CTE + count wrapper + allArgs := append(cteArgs, filterArgs...) + countQuery := cte + `, +api_violations AS ( + SELECT + lr.api_id, + ARRAY_AGG(DISTINCT lm.code ORDER BY lm.code) AS violated_rules + FROM latest_results lr + JOIN lint_messages lm ON lm.lint_result_id = lr.lint_result_id + WHERE lm.severity = 'error' + GROUP BY lr.api_id +) +SELECT COUNT(*) +FROM latest_results lr +LEFT JOIN api_violations av ON av.api_id = lr.api_id` + whereClause + + var totalCount int + if err := r.db.WithContext(ctx).Raw(countQuery, allArgs...).Scan(&totalCount).Error; err != nil { + return nil, 0, fmt.Errorf("apis count query failed: %w", err) + } + + // Data query with pagination + offset := (params.Page - 1) * params.PerPage + dataQuery := baseQuery + whereClause + fmt.Sprintf(" ORDER BY a.title LIMIT %d OFFSET %d", params.PerPage, offset) + dataArgs := append(cteArgs, filterArgs...) + + var rows []ApiRow + if err := r.db.WithContext(ctx).Raw(dataQuery, dataArgs...).Scan(&rows).Error; err != nil { + return nil, 0, fmt.Errorf("apis query failed: %w", err) + } + + for i := range rows { + if rows[i].ViolatedRules == nil { + rows[i].ViolatedRules = pq.StringArray{} + } + } + + return rows, totalCount, nil +} diff --git a/pkg/api_client/routers.go b/pkg/api_client/routers.go index 1018349..04026b6 100644 --- a/pkg/api_client/routers.go +++ b/pkg/api_client/routers.go @@ -40,7 +40,7 @@ var ( ) ) -func NewRouter(apiVersion string, controller *handler.APIsAPIController) *fizz.Fizz { +func NewRouter(apiVersion string, controller *handler.APIsAPIController, statsController *handler.StatisticsController) *fizz.Fizz { //gin.SetMode(gin.ReleaseMode) g := gin.Default() @@ -228,6 +228,49 @@ func NewRouter(apiVersion string, controller *handler.APIsAPIController) *fizz.F tonic.Handler(controller.UpdateApi, 200), ) + // Statistics endpoints + statsGroup := f.Group("/v1/statistics", "Statistics", "ADR adoption statistics endpoints") + statsGroup.GET("/summary", + []fizz.OperationOption{ + fizz.ID("getAdoptionSummary"), + fizz.Summary("Get adoption summary"), + fizz.Description("Returns overall adoption KPIs for the selected period"), + apiVersionHeaderOption, + badRequestResponse, + }, + tonic.Handler(statsController.GetSummary, 200), + ) + statsGroup.GET("/rules", + []fizz.OperationOption{ + fizz.ID("getAdoptionRules"), + fizz.Summary("Get adoption per rule"), + fizz.Description("Returns adoption rate per ADR rule"), + apiVersionHeaderOption, + badRequestResponse, + }, + tonic.Handler(statsController.GetRules, 200), + ) + statsGroup.GET("/timeline", + []fizz.OperationOption{ + fizz.ID("getAdoptionTimeline"), + fizz.Summary("Get adoption timeline"), + fizz.Description("Returns adoption over time for charts"), + apiVersionHeaderOption, + badRequestResponse, + }, + tonic.Handler(statsController.GetTimeline, 200), + ) + statsGroup.GET("/apis", + []fizz.OperationOption{ + fizz.ID("getAdoptionApis"), + fizz.Summary("Get APIs with adoption status"), + fizz.Description("Returns list of APIs with their compliance status"), + apiVersionHeaderOption, + badRequestResponse, + }, + tonic.Handler(statsController.GetApis, 200), + ) + // 6) OpenAPI documentatie g.StaticFile("/v1/openapi.json", "./api/openapi.json") diff --git a/pkg/api_client/services/adoption_service.go b/pkg/api_client/services/adoption_service.go new file mode 100644 index 0000000..65524a9 --- /dev/null +++ b/pkg/api_client/services/adoption_service.go @@ -0,0 +1,291 @@ +package services + +import ( + "context" + "fmt" + "math" + "strings" + "time" + + "github.com/developer-overheid-nl/don-api-register/pkg/api_client/models" + "github.com/developer-overheid-nl/don-api-register/pkg/api_client/repositories" +) + +type AdoptionService struct { + repo repositories.AdoptionRepository +} + +func NewAdoptionService(repo repositories.AdoptionRepository) *AdoptionService { + return &AdoptionService{repo: repo} +} + +func (s *AdoptionService) GetSummary(ctx context.Context, p *models.AdoptionBaseParams) (*models.AdoptionSummary, error) { + startDate, endDate, err := parseDateRange(p.StartDate, p.EndDate) + if err != nil { + return nil, err + } + + params := repositories.AdoptionQueryParams{ + AdrVersion: p.AdrVersion, + StartDate: startDate, + EndDate: endDate, + ApiIds: splitCSV(p.ApiIds), + Organisation: trimOptional(p.Organisation), + } + + result, totalRuns, err := s.repo.GetSummary(ctx, params) + if err != nil { + return nil, err + } + + return &models.AdoptionSummary{ + AdrVersion: p.AdrVersion, + Period: models.Period{ + Start: p.StartDate, + End: p.EndDate, + }, + TotalApis: result.TotalApis, + CompliantApis: result.CompliantApis, + OverallAdoptionRate: result.AdoptionRate, + TotalLintRuns: totalRuns, + }, nil +} + +func (s *AdoptionService) GetRules(ctx context.Context, p *models.AdoptionRulesParams) (*models.AdoptionRules, error) { + _, endDate, err := parseDateRange(p.StartDate, p.EndDate) + if err != nil { + return nil, err + } + + params := repositories.AdoptionQueryParams{ + AdrVersion: p.AdrVersion, + EndDate: endDate, + ApiIds: splitCSV(p.ApiIds), + Organisation: trimOptional(p.Organisation), + RuleCodes: splitCSV(p.RuleCodes), + Severity: trimOptional(p.Severity), + } + + rows, totalApis, err := s.repo.GetRules(ctx, params) + if err != nil { + return nil, err + } + + rules := make([]models.RuleAdoption, len(rows)) + for i, row := range rows { + compliant := totalApis - row.ViolatingApis + rules[i] = models.RuleAdoption{ + Code: row.Code, + Severity: row.Severity, + ViolatingApis: row.ViolatingApis, + CompliantApis: compliant, + AdoptionRate: adoptionRate(compliant, totalApis), + } + } + + return &models.AdoptionRules{ + AdrVersion: p.AdrVersion, + Period: models.Period{ + Start: p.StartDate, + End: p.EndDate, + }, + TotalApis: totalApis, + Rules: rules, + }, nil +} + +func (s *AdoptionService) GetTimeline(ctx context.Context, p *models.AdoptionTimelineParams) (*models.AdoptionTimeline, error) { + startDate, endDate, err := parseDateRange(p.StartDate, p.EndDate) + if err != nil { + return nil, err + } + + granularity := p.Granularity + if granularity == "" { + granularity = "month" + } + if granularity != "day" && granularity != "week" && granularity != "month" { + return nil, fmt.Errorf("invalid granularity: %s (must be day, week, or month)", granularity) + } + + params := repositories.TimelineQueryParams{ + AdoptionQueryParams: repositories.AdoptionQueryParams{ + AdrVersion: p.AdrVersion, + StartDate: startDate, + EndDate: endDate, + ApiIds: splitCSV(p.ApiIds), + Organisation: trimOptional(p.Organisation), + RuleCodes: splitCSV(p.RuleCodes), + }, + Granularity: granularity, + } + + rows, err := s.repo.GetTimeline(ctx, params) + if err != nil { + return nil, err + } + + // Group rows by rule code into series + seriesMap := make(map[string]*models.TimelineSeries) + var seriesOrder []string + for _, row := range rows { + series, ok := seriesMap[row.RuleCode] + if !ok { + series = &models.TimelineSeries{ + Type: "rule", + RuleCode: row.RuleCode, + } + seriesMap[row.RuleCode] = series + seriesOrder = append(seriesOrder, row.RuleCode) + } + series.DataPoints = append(series.DataPoints, models.TimelinePoint{ + Period: row.Period, + TotalApis: row.TotalApis, + CompliantApis: row.CompliantApis, + AdoptionRate: adoptionRate(row.CompliantApis, row.TotalApis), + }) + } + + series := make([]models.TimelineSeries, 0, len(seriesOrder)) + for _, code := range seriesOrder { + series = append(series, *seriesMap[code]) + } + + return &models.AdoptionTimeline{ + AdrVersion: p.AdrVersion, + Granularity: granularity, + Series: series, + }, nil +} + +func (s *AdoptionService) GetApis(ctx context.Context, p *models.AdoptionApisParams) (*models.AdoptionApis, *models.Pagination, error) { + _, endDate, err := parseDateRange(p.StartDate, p.EndDate) + if err != nil { + return nil, nil, err + } + + if p.Page < 1 { + p.Page = 1 + } + if p.PerPage < 1 { + p.PerPage = 20 + } + if p.PerPage > 100 { + p.PerPage = 100 + } + + params := repositories.ApisQueryParams{ + AdoptionQueryParams: repositories.AdoptionQueryParams{ + AdrVersion: p.AdrVersion, + EndDate: endDate, + ApiIds: splitCSV(p.ApiIds), + Organisation: trimOptional(p.Organisation), + RuleCodes: splitCSV(p.RuleCodes), + }, + Compliant: p.Compliant, + Page: p.Page, + PerPage: p.PerPage, + } + + rows, totalCount, err := s.repo.GetApis(ctx, params) + if err != nil { + return nil, nil, err + } + + apis := make([]models.ApiAdoption, len(rows)) + for i, row := range rows { + apis[i] = models.ApiAdoption{ + ApiId: row.ApiId, + ApiTitle: row.ApiTitle, + Organisation: row.Organisation, + IsCompliant: row.IsCompliant, + TotalViolations: row.TotalFailures, + TotalWarnings: row.TotalWarnings, + ViolatedRules: []string(row.ViolatedRules), + LastLintDate: row.LastLintDate, + } + } + + totalPages := 0 + if totalCount > 0 { + totalPages = int(math.Ceil(float64(totalCount) / float64(p.PerPage))) + } + + pagination := models.Pagination{ + CurrentPage: p.Page, + RecordsPerPage: p.PerPage, + TotalPages: totalPages, + TotalRecords: totalCount, + } + if p.Page < totalPages { + next := p.Page + 1 + pagination.Next = &next + } + if p.Page > 1 { + prev := p.Page - 1 + pagination.Previous = &prev + } + + return &models.AdoptionApis{ + AdrVersion: p.AdrVersion, + Period: models.Period{ + Start: p.StartDate, + End: p.EndDate, + }, + Apis: apis, + }, &pagination, nil +} + +func parseDateRange(startStr, endStr string) (time.Time, time.Time, error) { + start, err := time.Parse("2006-01-02", startStr) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid startDate: %w", err) + } + end, err := time.Parse("2006-01-02", endStr) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid endDate: %w", err) + } + // Set end to end of day + end = end.Add(23*time.Hour + 59*time.Minute + 59*time.Second) + return start, end, nil +} + +func splitCSV(s *string) []string { + if s == nil { + return nil + } + trimmed := strings.TrimSpace(*s) + if trimmed == "" { + return nil + } + parts := strings.Split(trimmed, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + t := strings.TrimSpace(p) + if t != "" { + result = append(result, t) + } + } + if len(result) == 0 { + return nil + } + return result +} + +func trimOptional(s *string) *string { + if s == nil { + return nil + } + trimmed := strings.TrimSpace(*s) + if trimmed == "" { + return nil + } + return &trimmed +} + +func adoptionRate(compliant, total int) float64 { + if total == 0 { + return 0 + } + return math.Round(float64(compliant)/float64(total)*1000) / 10 +} From d69f3462576b9a84f54dea22c8397721ecd7a073 Mon Sep 17 00:00:00 2001 From: pasibun Date: Tue, 24 Feb 2026 09:38:17 +0100 Subject: [PATCH 02/15] Remove ListLintResults functionality from API and related tests --- pkg/api_client/handler/api_handler.go | 5 ----- pkg/api_client/handler/api_handler_test.go | 7 ------- pkg/api_client/integration_test.go | 5 ++++- pkg/api_client/repositories/api_repositorie.go | 14 -------------- pkg/api_client/routers.go | 15 --------------- pkg/api_client/services/api_service.go | 4 ---- pkg/api_client/services/api_service_oas_test.go | 3 --- pkg/api_client/services/api_service_test.go | 7 ------- 8 files changed, 4 insertions(+), 56 deletions(-) diff --git a/pkg/api_client/handler/api_handler.go b/pkg/api_client/handler/api_handler.go index c79f2e1..6dc98db 100644 --- a/pkg/api_client/handler/api_handler.go +++ b/pkg/api_client/handler/api_handler.go @@ -70,11 +70,6 @@ func (c *APIsAPIController) RetrieveApi(ctx *gin.Context, params *models.ApiPara return api, nil } -// ListLintResults handles GET /lint-results -func (c *APIsAPIController) ListLintResults(ctx *gin.Context) ([]models.LintResult, error) { - return c.Service.ListLintResults(ctx.Request.Context()) -} - // CreateApiFromOas handles POST /apis func (c *APIsAPIController) CreateApiFromOas(ctx *gin.Context, body *models.ApiPost) (*models.ApiSummary, error) { created, err := c.Service.CreateApiFromOas(*body) diff --git a/pkg/api_client/handler/api_handler_test.go b/pkg/api_client/handler/api_handler_test.go index 4d158d9..d0aab36 100644 --- a/pkg/api_client/handler/api_handler_test.go +++ b/pkg/api_client/handler/api_handler_test.go @@ -20,7 +20,6 @@ type stubRepo struct { searchFunc func(ctx context.Context, page, perPage int, organisation *string, query string) ([]models.Api, models.Pagination, error) retrFunc func(ctx context.Context, id string) (*models.Api, error) lintResFunc func(ctx context.Context, apiID string) ([]models.LintResult, error) - listLint func(ctx context.Context) ([]models.LintResult, error) findOasFunc func(ctx context.Context, oasUrl string) (*models.Api, error) getOrgs func(ctx context.Context) ([]models.Organisation, int, error) findOrg func(ctx context.Context, uri string) (*models.Organisation, error) @@ -46,12 +45,6 @@ func (s *stubRepo) GetApiByID(ctx context.Context, id string) (*models.Api, erro func (s *stubRepo) GetLintResults(ctx context.Context, apiID string) ([]models.LintResult, error) { return s.lintResFunc(ctx, apiID) } -func (s *stubRepo) ListLintResults(ctx context.Context) ([]models.LintResult, error) { - if s.listLint != nil { - return s.listLint(ctx) - } - return []models.LintResult{}, nil -} func (s *stubRepo) FindByOasUrl(ctx context.Context, oasUrl string) (*models.Api, error) { return s.findOasFunc(ctx, oasUrl) } diff --git a/pkg/api_client/integration_test.go b/pkg/api_client/integration_test.go index c214a01..9ba5873 100644 --- a/pkg/api_client/integration_test.go +++ b/pkg/api_client/integration_test.go @@ -130,7 +130,10 @@ func newIntegrationEnv(t *testing.T) *integrationEnv { repo := repositories.NewApiRepository(db) svc := services.NewAPIsAPIService(repo) controller := handler.NewAPIsAPIController(svc) - router := api_client.NewRouter("test-version", controller) + adoptionRepo := repositories.NewAdoptionRepository(db) + adoptionService := services.NewAdoptionService(adoptionRepo) + statsController := handler.NewStatisticsController(adoptionService) + router := api_client.NewRouter("test-version", controller, statsController) server := httptest.NewServer(router) t.Cleanup(func() { server.Close() }) diff --git a/pkg/api_client/repositories/api_repositorie.go b/pkg/api_client/repositories/api_repositorie.go index 2c7bc14..b12fd10 100644 --- a/pkg/api_client/repositories/api_repositorie.go +++ b/pkg/api_client/repositories/api_repositorie.go @@ -23,7 +23,6 @@ type ApiRepository interface { AllApis(ctx context.Context) ([]models.Api, error) SaveLintResult(ctx context.Context, result *models.LintResult) error GetLintResults(ctx context.Context, apiID string) ([]models.LintResult, error) - ListLintResults(ctx context.Context) ([]models.LintResult, error) GetOrganisations(ctx context.Context) ([]models.Organisation, int, error) FindOrganisationByURI(ctx context.Context, uri string) (*models.Organisation, error) SaveArtifact(ctx context.Context, art *models.ApiArtifact) error @@ -239,19 +238,6 @@ func (r *apiRepository) GetLintResults(ctx context.Context, apiID string) ([]mod return results, nil } -func (r *apiRepository) ListLintResults(ctx context.Context) ([]models.LintResult, error) { - var results []models.LintResult - err := r.db.WithContext(ctx). - Preload("Messages"). - Preload("Messages.Infos"). - Order("created_at desc"). - Find(&results).Error - if err != nil { - return nil, err - } - return results, nil -} - func (r *apiRepository) GetOrganisations(ctx context.Context) ([]models.Organisation, int, error) { var organisations []models.Organisation var total int64 diff --git a/pkg/api_client/routers.go b/pkg/api_client/routers.go index 04026b6..70922b6 100644 --- a/pkg/api_client/routers.go +++ b/pkg/api_client/routers.go @@ -182,21 +182,6 @@ func NewRouter(apiVersion string, controller *handler.APIsAPIController, statsCo tonic.Handler(controller.CreateOrganisation, 201), ) - privateApis.GET("/lint-results", - []fizz.OperationOption{ - fizz.ID("listLintResults"), - fizz.Summary("List all lint results"), - fizz.Description("Returns all lint results."), - fizz.WithOptionalSecurity(), - fizz.Security(&openapi.SecurityRequirement{ - "clientCredentials": {"apis:read"}, - }), - apiVersionHeaderOption, - badRequestResponse, - }, - tonic.Handler(controller.ListLintResults, 200), - ) - privateApis.POST("/apis", []fizz.OperationOption{ fizz.ID("createApi"), diff --git a/pkg/api_client/services/api_service.go b/pkg/api_client/services/api_service.go index 99c6af2..3a20bb7 100644 --- a/pkg/api_client/services/api_service.go +++ b/pkg/api_client/services/api_service.go @@ -148,10 +148,6 @@ func (s *APIsAPIService) RetrieveApi(ctx context.Context, id string) (*models.Ap return detail, nil } -func (s *APIsAPIService) ListLintResults(ctx context.Context) ([]models.LintResult, error) { - return s.repo.ListLintResults(ctx) -} - func (s *APIsAPIService) ListApis(ctx context.Context, p *models.ListApisParams) ([]models.ApiSummary, models.Pagination, error) { idFilter := p.FilterIDs() apis, pagination, err := s.repo.GetApis(ctx, p.Page, p.PerPage, p.Organisation, idFilter) diff --git a/pkg/api_client/services/api_service_oas_test.go b/pkg/api_client/services/api_service_oas_test.go index 24e9729..d25a928 100644 --- a/pkg/api_client/services/api_service_oas_test.go +++ b/pkg/api_client/services/api_service_oas_test.go @@ -50,9 +50,6 @@ func (a *artifactRepoStub) SaveLintResult(ctx context.Context, res *models.LintR func (a *artifactRepoStub) GetLintResults(ctx context.Context, apiID string) ([]models.LintResult, error) { return nil, nil } -func (a *artifactRepoStub) ListLintResults(ctx context.Context) ([]models.LintResult, error) { - return nil, nil -} func (a *artifactRepoStub) GetOrganisations(ctx context.Context) ([]models.Organisation, int, error) { return nil, 0, nil } diff --git a/pkg/api_client/services/api_service_test.go b/pkg/api_client/services/api_service_test.go index 98d9465..f7e85da 100644 --- a/pkg/api_client/services/api_service_test.go +++ b/pkg/api_client/services/api_service_test.go @@ -21,7 +21,6 @@ type stubRepo struct { findOrg func(ctx context.Context, uri string) (*models.Organisation, error) getByID func(ctx context.Context, id string) (*models.Api, error) getLintRes func(ctx context.Context, apiID string) ([]models.LintResult, error) - listLintRes func(ctx context.Context) ([]models.LintResult, error) getApis func(ctx context.Context, page, perPage int, organisation *string, ids *string) ([]models.Api, models.Pagination, error) searchApis func(ctx context.Context, page, perPage int, organisation *string, query string) ([]models.Api, models.Pagination, error) saveServer func(server models.Server) error @@ -48,12 +47,6 @@ func (s *stubRepo) GetLintResults(ctx context.Context, apiID string) ([]models.L } return nil, nil } -func (s *stubRepo) ListLintResults(ctx context.Context) ([]models.LintResult, error) { - if s.listLintRes != nil { - return s.listLintRes(ctx) - } - return []models.LintResult{}, nil -} func (s *stubRepo) GetApis(ctx context.Context, page, perPage int, organisation *string, ids *string) ([]models.Api, models.Pagination, error) { return s.getApis(ctx, page, perPage, organisation, ids) } From 3bd7fcfbe2ef1a08785027f4b49edc027d437f9c Mon Sep 17 00:00:00 2001 From: pasibun Date: Tue, 24 Feb 2026 15:02:18 +0100 Subject: [PATCH 03/15] Add statistics endpoints for API adoption and compliance tracking --- api/openapi.json | 966 +++++++++++++++++++++++++++++++---------------- 1 file changed, 641 insertions(+), 325 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 1e91f00..190757b 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -38,6 +38,10 @@ { "name": "Private endpoints", "description": "Private endpoints of the API register, accessible with a client credentials token." + }, + { + "description": "ADR adoption statistics endpoints", + "name": "Statistics" } ], "paths": { @@ -1233,233 +1237,6 @@ } } }, - "/lint-results": { - "get": { - "security": [ - {}, - { - "clientCredentials": [ - "apis:read" - ] - } - ], - "tags": [ - "Private endpoints", - "APIs" - ], - "summary": "List all lint results", - "description": "Returns all lint results.", - "operationId": "listLintResults", - "responses": { - "200": { - "description": "OK", - "headers": { - "API-Version": { - "description": "Semver of this API", - "schema": { - "type": "string", - "example": "1.0.0", - "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", - "externalDocs": { - "description": " /core/version-header: Return the full version number in a response header", - "url": "https://logius-standaarden.github.io/API-Design-Rules/#/core/version-header" - } - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "ID": { - "type": "string" - }, - "ApiID": { - "type": "string" - }, - "successes": { - "type": "boolean" - }, - "failures": { - "type": "integer" - }, - "warnings": { - "type": "integer" - }, - "CreatedAt": { - "type": "string", - "format": "date-time" - }, - "messages": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "lintResultId": { - "type": "string" - }, - "line": { - "type": "integer" - }, - "column": { - "type": "integer" - }, - "severity": { - "type": "string" - }, - "code": { - "type": "string" - }, - "CreatedAt": { - "type": "string", - "format": "date-time" - }, - "infos": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "lintMessageId": { - "type": "string" - }, - "message": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "id", - "lintMessageId", - "message", - "path" - ] - } - } - }, - "required": [ - "id", - "lintResultId", - "line", - "column", - "severity", - "code", - "CreatedAt" - ] - } - }, - "rulesetVersion": { - "type": "string" - } - }, - "required": [ - "ID", - "ApiID", - "successes", - "failures", - "warnings", - "CreatedAt" - ] - } - } - } - } - }, - "400": { - "headers": { - "API-Version": { - "description": "Semver of this API", - "schema": { - "type": "string", - "example": "1.0.0", - "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", - "externalDocs": { - "description": " /core/version-header: Return the full version number in a response header", - "url": "https://logius-standaarden.github.io/API-Design-Rules/#/core/version-header" - } - } - } - }, - "description": "Bad request", - "content": { - "application/problem+json": { - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://example.com/ProblemJson.schema.json", - "title": "Problem JSON", - "description": "Problem JSON schema representing errors and status code", - "type": "object", - "properties": { - "status": { - "type": "integer", - "description": "The HTTP status code generated by the origin server for this occurrence of the problem", - "example": 400 - }, - "title": { - "type": "string", - "description": "A short, human-readable summary of the problem type", - "example": "Request validation failed" - }, - "errors": { - "type": "array", - "items": { - "type": "object", - "description": "List of errors encountered during processing", - "properties": { - "in": { - "type": "string", - "description": "Location of the error (e.g., body, query, header)", - "enum": [ - "body", - "query" - ] - }, - "location": { - "type": "string", - "description": "Location in the document where the error occurred (JSON Pointer)", - "example": "#/foo[0]/bar" - }, - "code": { - "type": "string", - "description": "A code representing the type of error", - "example": "date.format" - }, - "detail": { - "type": "string", - "description": "A detailed message describing the error", - "example": "must be ISO 8601" - } - }, - "required": [ - "in", - "location", - "code", - "detail" - ] - } - } - }, - "required": [ - "status", - "title" - ] - } - } - } - } - } - } - }, "/apis/{id}": { "parameters": [ { @@ -2854,105 +2631,456 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "name": "X-Api-Key", - "in": "header" - }, - "clientCredentials": { - "type": "oauth2", - "flows": { - "clientCredentials": { - "scopes": { - "apis:read": "Read access to APIs", - "apis:write": "Write access to APIIs", - "organisations:read": "Read access to organisations", - "organisations:write": "Write access to organisations", - "tools": "Access to tools", - "repositories:read": "Read access to repositories", - "repositories:write": "Write access to repositories", - "gitOrganisations:read": "Read access to git organisations", - "gitOrganisations:write": "Write access to git organisations" - }, - "tokenUrl": "https://auth.developer.overheid.nl/realms/don/protocol/openid-connect/token" - } - } - } - }, - "parameters": { - "page": { - "name": "page", - "in": "query", - "required": false, - "description": "Page number (1-based).", - "schema": { - "type": "integer", - "minimum": 1, - "default": 1 - } - }, - "perPage": { - "name": "perPage", - "in": "query", - "required": false, - "description": "Number of results per page.", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - } - }, - "organisation": { - "name": "organisation", - "in": "query", - "required": false, - "description": "Filter on organisation URI.", - "schema": { - "type": "string", - "format": "uri" - } - }, - "q": { - "name": "q", - "in": "query", - "required": true, - "description": "Search term.", - "schema": { - "type": "string" - } - }, - "id": { - "name": "id", - "in": "path", - "required": true, - "description": "Unique identifier of the resource.", - "schema": { - "type": "string" - } - } }, - "headers": { - "API-Version": { - "description": "Semver of this API", - "schema": { - "type": "string", - "example": "1.0.0", - "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", - "externalDocs": { - "description": " /core/version-header: Return the full version number in a response header", - "url": "https://logius-standaarden.github.io/API-Design-Rules/#/core/version-header" - } - } - }, - "Link": { - "description": "Links to the previous, next, last or first pages", - "schema": { - "type": "string", + "/statistics/apis": { + "get": { + "description": "Returns list of APIs with their compliance status", + "operationId": "getAdoptionApis", + "parameters": [ + { + "in": "query", + "name": "adrVersion", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "apiIds", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "compliant", + "schema": { + "nullable": true, + "type": "boolean" + } + }, + { + "in": "query", + "name": "endDate", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "organisation", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "in": "query", + "name": "perPage", + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "in": "query", + "name": "ruleCodes", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "startDate", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelsAdoptionApis" + } + } + }, + "description": "OK", + "headers": { + "API-Version": { + "description": "De API-versie van de response", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "$ref": "#/components/responses/400" + } + }, + "summary": "Get APIs with adoption status", + "tags": [ + "Statistics" + ] + } + }, + "/statistics/rules": { + "get": { + "description": "Returns adoption rate per ADR rule", + "operationId": "getAdoptionRules", + "parameters": [ + { + "in": "query", + "name": "adrVersion", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "apiIds", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "endDate", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "organisation", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "ruleCodes", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "severity", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "startDate", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelsAdoptionRules" + } + } + }, + "description": "OK", + "headers": { + "API-Version": { + "description": "De API-versie van de response", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "$ref": "#/components/responses/400" + } + }, + "summary": "Get adoption per rule", + "tags": [ + "Statistics" + ] + } + }, + "/statistics/summary": { + "get": { + "description": "Returns overall adoption KPIs for the selected period", + "operationId": "getAdoptionSummary", + "parameters": [ + { + "in": "query", + "name": "adrVersion", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "apiIds", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "endDate", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "organisation", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "startDate", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelsAdoptionSummary" + } + } + }, + "description": "OK", + "headers": { + "API-Version": { + "description": "De API-versie van de response", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "$ref": "#/components/responses/400" + } + }, + "summary": "Get adoption summary", + "tags": [ + "Statistics" + ] + } + }, + "/statistics/timeline": { + "get": { + "description": "Returns adoption over time for charts", + "operationId": "getAdoptionTimeline", + "parameters": [ + { + "in": "query", + "name": "adrVersion", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "apiIds", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "endDate", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "granularity", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "organisation", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "ruleCodes", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "startDate", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelsAdoptionTimeline" + } + } + }, + "description": "OK", + "headers": { + "API-Version": { + "description": "De API-versie van de response", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "$ref": "#/components/responses/400" + } + }, + "summary": "Get adoption timeline", + "tags": [ + "Statistics" + ] + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "name": "X-Api-Key", + "in": "header" + }, + "clientCredentials": { + "type": "oauth2", + "flows": { + "clientCredentials": { + "scopes": { + "apis:read": "Read access to APIs", + "apis:write": "Write access to APIIs", + "organisations:read": "Read access to organisations", + "organisations:write": "Write access to organisations", + "tools": "Access to tools", + "repositories:read": "Read access to repositories", + "repositories:write": "Write access to repositories", + "gitOrganisations:read": "Read access to git organisations", + "gitOrganisations:write": "Write access to git organisations" + }, + "tokenUrl": "https://auth.developer.overheid.nl/realms/don/protocol/openid-connect/token" + } + } + } + }, + "parameters": { + "page": { + "name": "page", + "in": "query", + "required": false, + "description": "Page number (1-based).", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + "perPage": { + "name": "perPage", + "in": "query", + "required": false, + "description": "Number of results per page.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + "organisation": { + "name": "organisation", + "in": "query", + "required": false, + "description": "Filter on organisation URI.", + "schema": { + "type": "string", + "format": "uri" + } + }, + "q": { + "name": "q", + "in": "query", + "required": true, + "description": "Search term.", + "schema": { + "type": "string" + } + }, + "id": { + "name": "id", + "in": "path", + "required": true, + "description": "Unique identifier of the resource.", + "schema": { + "type": "string" + } + } + }, + "headers": { + "API-Version": { + "description": "Semver of this API", + "schema": { + "type": "string", + "example": "1.0.0", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", + "externalDocs": { + "description": " /core/version-header: Return the full version number in a response header", + "url": "https://logius-standaarden.github.io/API-Design-Rules/#/core/version-header" + } + } + }, + "Link": { + "description": "Links to the previous, next, last or first pages", + "schema": { + "type": "string", "example": "; rel='prev', ; rel='next'", "externalDocs": { "description": "W3C reference", @@ -3711,6 +3839,194 @@ ] } ] + }, + "ModelsTimelineSeries": { + "properties": { + "dataPoints": { + "items": { + "$ref": "#/components/schemas/ModelsTimelinePoint" + }, + "type": "array" + }, + "ruleCode": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "ModelsAdoptionRules": { + "properties": { + "adrVersion": { + "type": "string" + }, + "period": { + "$ref": "#/components/schemas/ModelsPeriod" + }, + "rules": { + "items": { + "$ref": "#/components/schemas/ModelsRuleAdoption" + }, + "type": "array" + }, + "totalApis": { + "format": "int32", + "type": "integer" + } + }, + "type": "object" + }, + "ModelsAdoptionSummary": { + "properties": { + "adrVersion": { + "type": "string" + }, + "compliantApis": { + "format": "int32", + "type": "integer" + }, + "overallAdoptionRate": { + "format": "double", + "type": "number" + }, + "period": { + "$ref": "#/components/schemas/ModelsPeriod" + }, + "totalApis": { + "format": "int32", + "type": "integer" + }, + "totalLintRuns": { + "format": "int32", + "type": "integer" + } + }, + "type": "object" + }, + "ModelsRuleAdoption": { + "properties": { + "adoptionRate": { + "format": "double", + "type": "number" + }, + "code": { + "type": "string" + }, + "compliantApis": { + "format": "int32", + "type": "integer" + }, + "severity": { + "type": "string" + }, + "violatingApis": { + "format": "int32", + "type": "integer" + } + }, + "type": "object" + }, + "ModelsAdoptionApis": { + "properties": { + "adrVersion": { + "type": "string" + }, + "apis": { + "items": { + "$ref": "#/components/schemas/ModelsApiAdoption" + }, + "type": "array" + }, + "period": { + "$ref": "#/components/schemas/ModelsPeriod" + } + }, + "type": "object" + }, + "ModelsAdoptionTimeline": { + "properties": { + "adrVersion": { + "type": "string" + }, + "granularity": { + "type": "string" + }, + "series": { + "items": { + "$ref": "#/components/schemas/ModelsTimelineSeries" + }, + "type": "array" + } + }, + "type": "object" + }, + "ModelsPeriod": { + "properties": { + "end": { + "type": "string" + }, + "start": { + "type": "string" + } + }, + "type": "object" + }, + "ModelsApiAdoption": { + "properties": { + "apiId": { + "type": "string" + }, + "apiTitle": { + "type": "string" + }, + "isCompliant": { + "type": "boolean" + }, + "lastLintDate": { + "format": "date-time", + "type": "string" + }, + "organisation": { + "type": "string" + }, + "totalViolations": { + "format": "int32", + "type": "integer" + }, + "totalWarnings": { + "format": "int32", + "type": "integer" + }, + "violatedRules": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "ModelsTimelinePoint": { + "properties": { + "adoptionRate": { + "format": "double", + "type": "number" + }, + "compliantApis": { + "format": "int32", + "type": "integer" + }, + "period": { + "type": "string" + }, + "totalApis": { + "format": "int32", + "type": "integer" + } + }, + "type": "object" } }, "responses": { From 8945aa935c3fe71e2997b6aeb4d0289fa7c1b24b Mon Sep 17 00:00:00 2001 From: Matthijs Hovestad Date: Tue, 24 Feb 2026 15:03:22 +0100 Subject: [PATCH 04/15] Update pkg/api_client/services/adoption_service.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/api_client/services/adoption_service.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/api_client/services/adoption_service.go b/pkg/api_client/services/adoption_service.go index 65524a9..2c131c7 100644 --- a/pkg/api_client/services/adoption_service.go +++ b/pkg/api_client/services/adoption_service.go @@ -57,13 +57,19 @@ func (s *AdoptionService) GetRules(ctx context.Context, p *models.AdoptionRulesP return nil, err } + // Validate severity according to adr-adoption-plan.md: only "error" or "warning" are allowed. + severity := strings.ToLower(strings.TrimSpace(p.Severity)) + if severity != "" && severity != "error" && severity != "warning" { + return nil, fmt.Errorf("invalid severity %q: must be \"error\" or \"warning\"", p.Severity) + } + params := repositories.AdoptionQueryParams{ AdrVersion: p.AdrVersion, EndDate: endDate, ApiIds: splitCSV(p.ApiIds), Organisation: trimOptional(p.Organisation), RuleCodes: splitCSV(p.RuleCodes), - Severity: trimOptional(p.Severity), + Severity: severity, } rows, totalApis, err := s.repo.GetRules(ctx, params) From 1b7de02903fe1b05b4533fa52fb0ec4454d6ee10 Mon Sep 17 00:00:00 2001 From: pasibun Date: Tue, 24 Feb 2026 15:06:41 +0100 Subject: [PATCH 05/15] Fix parseDateRange to set end date to the end of the day correctly --- pkg/api_client/services/adoption_service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/api_client/services/adoption_service.go b/pkg/api_client/services/adoption_service.go index 2c131c7..20bbb19 100644 --- a/pkg/api_client/services/adoption_service.go +++ b/pkg/api_client/services/adoption_service.go @@ -252,7 +252,7 @@ func parseDateRange(startStr, endStr string) (time.Time, time.Time, error) { return time.Time{}, time.Time{}, fmt.Errorf("invalid endDate: %w", err) } // Set end to end of day - end = end.Add(23*time.Hour + 59*time.Minute + 59*time.Second) + end = end.Add(24 * time.Hour) return start, end, nil } From 9656aadbcdcead784c8a7d63e8f062390cdd6ee0 Mon Sep 17 00:00:00 2001 From: pasibun Date: Tue, 24 Feb 2026 15:52:14 +0100 Subject: [PATCH 06/15] Add unit tests for adoption service and repository functionalities --- .../handler/statistics_handler_test.go | 143 +++++++++ .../repositories/adoption_repository_test.go | 115 +++++++ pkg/api_client/services/adoption_service.go | 12 +- .../services/adoption_service_test.go | 297 ++++++++++++++++++ 4 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 pkg/api_client/handler/statistics_handler_test.go create mode 100644 pkg/api_client/repositories/adoption_repository_test.go create mode 100644 pkg/api_client/services/adoption_service_test.go diff --git a/pkg/api_client/handler/statistics_handler_test.go b/pkg/api_client/handler/statistics_handler_test.go new file mode 100644 index 0000000..f43a7bd --- /dev/null +++ b/pkg/api_client/handler/statistics_handler_test.go @@ -0,0 +1,143 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/developer-overheid-nl/don-api-register/pkg/api_client/models" + "github.com/developer-overheid-nl/don-api-register/pkg/api_client/repositories" + "github.com/developer-overheid-nl/don-api-register/pkg/api_client/services" + "github.com/gin-gonic/gin" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type statisticsAdoptionRepoStub struct { + getSummaryFunc func(ctx context.Context, params repositories.AdoptionQueryParams) (repositories.SummaryResult, int, error) + getApisFunc func(ctx context.Context, params repositories.ApisQueryParams) ([]repositories.ApiRow, int, error) +} + +func (s *statisticsAdoptionRepoStub) GetSummary(ctx context.Context, params repositories.AdoptionQueryParams) (repositories.SummaryResult, int, error) { + if s.getSummaryFunc != nil { + return s.getSummaryFunc(ctx, params) + } + return repositories.SummaryResult{}, 0, nil +} + +func (s *statisticsAdoptionRepoStub) GetRules(ctx context.Context, params repositories.AdoptionQueryParams) ([]repositories.RuleRow, int, error) { + return nil, 0, nil +} + +func (s *statisticsAdoptionRepoStub) GetTimeline(ctx context.Context, params repositories.TimelineQueryParams) ([]repositories.TimelineRow, error) { + return nil, nil +} + +func (s *statisticsAdoptionRepoStub) GetApis(ctx context.Context, params repositories.ApisQueryParams) ([]repositories.ApiRow, int, error) { + if s.getApisFunc != nil { + return s.getApisFunc(ctx, params) + } + return nil, 0, nil +} + +func TestStatisticsControllerGetSummary_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &statisticsAdoptionRepoStub{ + getSummaryFunc: func(ctx context.Context, params repositories.AdoptionQueryParams) (repositories.SummaryResult, int, error) { + return repositories.SummaryResult{ + TotalApis: 5, + CompliantApis: 4, + AdoptionRate: 80, + }, 11, nil + }, + } + ctrl := NewStatisticsController(services.NewAdoptionService(repo)) + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/statistics/summary", nil) + + out, err := ctrl.GetSummary(ctx, &models.AdoptionBaseParams{ + AdrVersion: "ADR-2.0", + StartDate: "2026-01-01", + EndDate: "2026-01-31", + }) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, 5, out.TotalApis) + assert.Equal(t, 4, out.CompliantApis) + assert.Equal(t, 80.0, out.OverallAdoptionRate) + assert.Equal(t, 11, out.TotalLintRuns) +} + +func TestStatisticsControllerGetApis_SetsPaginationHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &statisticsAdoptionRepoStub{ + getApisFunc: func(ctx context.Context, params repositories.ApisQueryParams) ([]repositories.ApiRow, int, error) { + return []repositories.ApiRow{ + { + ApiId: "api-1", + ApiTitle: "API 1", + Organisation: "Org", + IsCompliant: true, + TotalFailures: 0, + TotalWarnings: 1, + ViolatedRules: pq.StringArray{}, + LastLintDate: time.Date(2026, 1, 10, 9, 0, 0, 0, time.UTC), + }, + }, 3, nil + }, + } + ctrl := NewStatisticsController(services.NewAdoptionService(repo)) + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodGet, "http://api.example.test/v1/statistics/apis?page=2&perPage=1", nil) + req.Host = "api.example.test" + ctx.Request = req + + out, err := ctrl.GetApis(ctx, &models.AdoptionApisParams{ + AdoptionBaseParams: models.AdoptionBaseParams{AdrVersion: "ADR-2.0", StartDate: "2026-01-01", EndDate: "2026-01-31"}, + Page: 2, + PerPage: 1, + }) + require.NoError(t, err) + require.NotNil(t, out) + require.Len(t, out.Apis, 1) + + assert.Equal(t, "3", w.Header().Get("Total-Count")) + assert.Equal(t, "3", w.Header().Get("Total-Pages")) + assert.Equal(t, "1", w.Header().Get("Per-Page")) + assert.Equal(t, "2", w.Header().Get("Current-Page")) + assert.Contains(t, w.Header().Get("Link"), `rel="prev"`) + assert.Contains(t, w.Header().Get("Link"), `rel="next"`) + assert.Contains(t, w.Header().Get("Link"), `page=2`) +} + +func TestStatisticsControllerGetApis_PropagatesErrorWithoutHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &statisticsAdoptionRepoStub{ + getApisFunc: func(ctx context.Context, params repositories.ApisQueryParams) ([]repositories.ApiRow, int, error) { + return nil, 0, errors.New("query failed") + }, + } + ctrl := NewStatisticsController(services.NewAdoptionService(repo)) + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/statistics/apis", nil) + + out, err := ctrl.GetApis(ctx, &models.AdoptionApisParams{ + AdoptionBaseParams: models.AdoptionBaseParams{AdrVersion: "ADR-2.0", StartDate: "2026-01-01", EndDate: "2026-01-31"}, + }) + + require.Error(t, err) + assert.Nil(t, out) + assert.Contains(t, err.Error(), "query failed") + assert.Empty(t, w.Header().Get("Total-Count")) + assert.Empty(t, w.Header().Get("Link")) +} diff --git a/pkg/api_client/repositories/adoption_repository_test.go b/pkg/api_client/repositories/adoption_repository_test.go new file mode 100644 index 0000000..067257b --- /dev/null +++ b/pkg/api_client/repositories/adoption_repository_test.go @@ -0,0 +1,115 @@ +package repositories + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupAdoptionRepoTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + return db +} + +func TestLatestResultsCTE_BuildsSQLWithFiltersAndArgsOrder(t *testing.T) { + end := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + org := " org-1 " + cte, args := latestResultsCTE(AdoptionQueryParams{ + AdrVersion: "ADR-2.0", + EndDate: end, + ApiIds: []string{"api-1", "api-2"}, + Organisation: &org, + }, "lr.api_id, lr.successes") + + assert.Contains(t, cte, "WITH latest_results AS") + assert.Contains(t, cte, "SELECT DISTINCT ON (lr.api_id) lr.api_id, lr.successes") + assert.Contains(t, cte, "lr.created_at <= ? AND lr.adr_version = ?") + assert.Contains(t, cte, "lr.api_id IN (?,?)") + assert.Contains(t, cte, "SELECT id FROM apis WHERE organisation_id = ?") + assert.Contains(t, cte, "ORDER BY lr.api_id, lr.created_at DESC") + + require.Len(t, args, 5) + assert.Equal(t, end, args[0]) + assert.Equal(t, "ADR-2.0", args[1]) + assert.Equal(t, "api-1", args[2]) + assert.Equal(t, "api-2", args[3]) + assert.Equal(t, "org-1", args[4]) +} + +func TestLatestResultsCTE_IgnoresBlankOrganisation(t *testing.T) { + org := " " + cte, args := latestResultsCTE(AdoptionQueryParams{ + AdrVersion: "ADR-2.0", + EndDate: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + Organisation: &org, + }, "lr.api_id") + + assert.NotContains(t, cte, "organisation_id = ?") + require.Len(t, args, 2) + assert.Equal(t, "ADR-2.0", args[1]) +} + +func TestAdoptionRepositoryGetSummary_WrapsQueryError(t *testing.T) { + repo := &adoptionRepository{db: setupAdoptionRepoTestDB(t)} + + _, _, err := repo.GetSummary(context.Background(), AdoptionQueryParams{ + AdrVersion: "ADR-2.0", + StartDate: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "summary query failed") +} + +func TestAdoptionRepositoryGetRules_WrapsQueryError(t *testing.T) { + repo := &adoptionRepository{db: setupAdoptionRepoTestDB(t)} + + _, _, err := repo.GetRules(context.Background(), AdoptionQueryParams{ + AdrVersion: "ADR-2.0", + EndDate: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "rules query failed") +} + +func TestAdoptionRepositoryGetTimeline_WrapsRuleCodesQueryError(t *testing.T) { + repo := &adoptionRepository{db: setupAdoptionRepoTestDB(t)} + + _, err := repo.GetTimeline(context.Background(), TimelineQueryParams{ + AdoptionQueryParams: AdoptionQueryParams{ + AdrVersion: "ADR-2.0", + StartDate: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + }, + Granularity: "month", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "rule codes query failed") +} + +func TestAdoptionRepositoryGetApis_WrapsCountQueryError(t *testing.T) { + repo := &adoptionRepository{db: setupAdoptionRepoTestDB(t)} + + _, _, err := repo.GetApis(context.Background(), ApisQueryParams{ + AdoptionQueryParams: AdoptionQueryParams{ + AdrVersion: "ADR-2.0", + EndDate: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + }, + Page: 1, + PerPage: 20, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "apis count query failed") +} diff --git a/pkg/api_client/services/adoption_service.go b/pkg/api_client/services/adoption_service.go index 20bbb19..19f240e 100644 --- a/pkg/api_client/services/adoption_service.go +++ b/pkg/api_client/services/adoption_service.go @@ -58,9 +58,15 @@ func (s *AdoptionService) GetRules(ctx context.Context, p *models.AdoptionRulesP } // Validate severity according to adr-adoption-plan.md: only "error" or "warning" are allowed. - severity := strings.ToLower(strings.TrimSpace(p.Severity)) - if severity != "" && severity != "error" && severity != "warning" { - return nil, fmt.Errorf("invalid severity %q: must be \"error\" or \"warning\"", p.Severity) + var severity *string + if p.Severity != nil { + normalized := strings.ToLower(strings.TrimSpace(*p.Severity)) + if normalized != "" && normalized != "error" && normalized != "warning" { + return nil, fmt.Errorf("invalid severity %q: must be \"error\" or \"warning\"", *p.Severity) + } + if normalized != "" { + severity = &normalized + } } params := repositories.AdoptionQueryParams{ diff --git a/pkg/api_client/services/adoption_service_test.go b/pkg/api_client/services/adoption_service_test.go new file mode 100644 index 0000000..4be9aa5 --- /dev/null +++ b/pkg/api_client/services/adoption_service_test.go @@ -0,0 +1,297 @@ +package services_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/developer-overheid-nl/don-api-register/pkg/api_client/models" + "github.com/developer-overheid-nl/don-api-register/pkg/api_client/repositories" + "github.com/developer-overheid-nl/don-api-register/pkg/api_client/services" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type adoptionRepoStub struct { + getSummaryFunc func(ctx context.Context, params repositories.AdoptionQueryParams) (repositories.SummaryResult, int, error) + getRulesFunc func(ctx context.Context, params repositories.AdoptionQueryParams) ([]repositories.RuleRow, int, error) + getTimelineFunc func(ctx context.Context, params repositories.TimelineQueryParams) ([]repositories.TimelineRow, error) + getApisFunc func(ctx context.Context, params repositories.ApisQueryParams) ([]repositories.ApiRow, int, error) +} + +func (s *adoptionRepoStub) GetSummary(ctx context.Context, params repositories.AdoptionQueryParams) (repositories.SummaryResult, int, error) { + if s.getSummaryFunc != nil { + return s.getSummaryFunc(ctx, params) + } + return repositories.SummaryResult{}, 0, nil +} + +func (s *adoptionRepoStub) GetRules(ctx context.Context, params repositories.AdoptionQueryParams) ([]repositories.RuleRow, int, error) { + if s.getRulesFunc != nil { + return s.getRulesFunc(ctx, params) + } + return nil, 0, nil +} + +func (s *adoptionRepoStub) GetTimeline(ctx context.Context, params repositories.TimelineQueryParams) ([]repositories.TimelineRow, error) { + if s.getTimelineFunc != nil { + return s.getTimelineFunc(ctx, params) + } + return nil, nil +} + +func (s *adoptionRepoStub) GetApis(ctx context.Context, params repositories.ApisQueryParams) ([]repositories.ApiRow, int, error) { + if s.getApisFunc != nil { + return s.getApisFunc(ctx, params) + } + return nil, 0, nil +} + +func strPtr(s string) *string { return &s } +func boolPtr(v bool) *bool { return &v } + +func TestAdoptionServiceGetSummary_Success(t *testing.T) { + var captured repositories.AdoptionQueryParams + repo := &adoptionRepoStub{ + getSummaryFunc: func(ctx context.Context, params repositories.AdoptionQueryParams) (repositories.SummaryResult, int, error) { + captured = params + return repositories.SummaryResult{ + TotalApis: 10, + CompliantApis: 7, + AdoptionRate: 70.0, + }, 42, nil + }, + } + svc := services.NewAdoptionService(repo) + + in := &models.AdoptionBaseParams{ + AdrVersion: "ADR-2.0", + StartDate: "2026-01-01", + EndDate: "2026-01-31", + ApiIds: strPtr(" api-1, , api-2 "), + Organisation: strPtr(" org-1 "), + } + + out, err := svc.GetSummary(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + + assert.Equal(t, "ADR-2.0", captured.AdrVersion) + assert.Equal(t, []string{"api-1", "api-2"}, captured.ApiIds) + if assert.NotNil(t, captured.Organisation) { + assert.Equal(t, "org-1", *captured.Organisation) + } + assert.Equal(t, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), captured.StartDate) + assert.Equal(t, time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), captured.EndDate) + + assert.Equal(t, "ADR-2.0", out.AdrVersion) + assert.Equal(t, models.Period{Start: "2026-01-01", End: "2026-01-31"}, out.Period) + assert.Equal(t, 10, out.TotalApis) + assert.Equal(t, 7, out.CompliantApis) + assert.Equal(t, 70.0, out.OverallAdoptionRate) + assert.Equal(t, 42, out.TotalLintRuns) +} + +func TestAdoptionServiceGetSummary_InvalidDate(t *testing.T) { + called := false + repo := &adoptionRepoStub{ + getSummaryFunc: func(ctx context.Context, params repositories.AdoptionQueryParams) (repositories.SummaryResult, int, error) { + called = true + return repositories.SummaryResult{}, 0, nil + }, + } + svc := services.NewAdoptionService(repo) + + _, err := svc.GetSummary(context.Background(), &models.AdoptionBaseParams{ + AdrVersion: "ADR-2.0", + StartDate: "not-a-date", + EndDate: "2026-01-31", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid startDate") + assert.False(t, called) +} + +func TestAdoptionServiceGetRules_Success(t *testing.T) { + var captured repositories.AdoptionQueryParams + repo := &adoptionRepoStub{ + getRulesFunc: func(ctx context.Context, params repositories.AdoptionQueryParams) ([]repositories.RuleRow, int, error) { + captured = params + return []repositories.RuleRow{ + {Code: "ADR-001", Severity: "warning", ViolatingApis: 2}, + {Code: "ADR-002", Severity: "warning", ViolatingApis: 10}, + }, 10, nil + }, + } + svc := services.NewAdoptionService(repo) + + out, err := svc.GetRules(context.Background(), &models.AdoptionRulesParams{ + AdoptionBaseParams: models.AdoptionBaseParams{ + AdrVersion: "ADR-2.0", + StartDate: "2026-01-01", + EndDate: "2026-01-31", + ApiIds: strPtr("api-1, api-2"), + Organisation: strPtr(" org-1 "), + }, + RuleCodes: strPtr("ADR-001, ADR-002"), + Severity: strPtr(" Warning "), + }) + require.NoError(t, err) + + if assert.NotNil(t, captured.Severity) { + assert.Equal(t, "warning", *captured.Severity) + } + assert.Equal(t, []string{"ADR-001", "ADR-002"}, captured.RuleCodes) + assert.Equal(t, []string{"api-1", "api-2"}, captured.ApiIds) + if assert.NotNil(t, captured.Organisation) { + assert.Equal(t, "org-1", *captured.Organisation) + } + assert.Equal(t, time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), captured.EndDate) + + require.Len(t, out.Rules, 2) + assert.Equal(t, 10, out.TotalApis) + assert.Equal(t, 8, out.Rules[0].CompliantApis) + assert.Equal(t, 80.0, out.Rules[0].AdoptionRate) + assert.Equal(t, 0, out.Rules[1].CompliantApis) + assert.Equal(t, 0.0, out.Rules[1].AdoptionRate) +} + +func TestAdoptionServiceGetRules_InvalidSeverity(t *testing.T) { + called := false + repo := &adoptionRepoStub{ + getRulesFunc: func(ctx context.Context, params repositories.AdoptionQueryParams) ([]repositories.RuleRow, int, error) { + called = true + return nil, 0, nil + }, + } + svc := services.NewAdoptionService(repo) + + _, err := svc.GetRules(context.Background(), &models.AdoptionRulesParams{ + AdoptionBaseParams: models.AdoptionBaseParams{AdrVersion: "ADR-2.0", StartDate: "2026-01-01", EndDate: "2026-01-31"}, + Severity: strPtr("fatal"), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid severity") + assert.False(t, called) +} + +func TestAdoptionServiceGetTimeline_Success_DefaultGranularityAndGrouping(t *testing.T) { + var captured repositories.TimelineQueryParams + repo := &adoptionRepoStub{ + getTimelineFunc: func(ctx context.Context, params repositories.TimelineQueryParams) ([]repositories.TimelineRow, error) { + captured = params + return []repositories.TimelineRow{ + {RuleCode: "ADR-001", Period: "2026-01", TotalApis: 10, CompliantApis: 7}, + {RuleCode: "ADR-001", Period: "2026-02", TotalApis: 12, CompliantApis: 9}, + {RuleCode: "ADR-002", Period: "2026-01", TotalApis: 10, CompliantApis: 10}, + }, nil + }, + } + svc := services.NewAdoptionService(repo) + + out, err := svc.GetTimeline(context.Background(), &models.AdoptionTimelineParams{ + AdoptionBaseParams: models.AdoptionBaseParams{AdrVersion: "ADR-2.0", StartDate: "2026-01-01", EndDate: "2026-02-28"}, + RuleCodes: strPtr("ADR-001,ADR-002"), + }) + require.NoError(t, err) + + assert.Equal(t, "month", captured.Granularity) + assert.Equal(t, []string{"ADR-001", "ADR-002"}, captured.RuleCodes) + assert.Equal(t, "month", out.Granularity) + require.Len(t, out.Series, 2) + assert.Equal(t, "ADR-001", out.Series[0].RuleCode) + require.Len(t, out.Series[0].DataPoints, 2) + assert.Equal(t, 70.0, out.Series[0].DataPoints[0].AdoptionRate) + assert.Equal(t, 75.0, out.Series[0].DataPoints[1].AdoptionRate) + assert.Equal(t, "ADR-002", out.Series[1].RuleCode) + assert.Equal(t, 100.0, out.Series[1].DataPoints[0].AdoptionRate) +} + +func TestAdoptionServiceGetTimeline_InvalidGranularity(t *testing.T) { + svc := services.NewAdoptionService(&adoptionRepoStub{}) + + _, err := svc.GetTimeline(context.Background(), &models.AdoptionTimelineParams{ + AdoptionBaseParams: models.AdoptionBaseParams{AdrVersion: "ADR-2.0", StartDate: "2026-01-01", EndDate: "2026-01-31"}, + Granularity: "quarter", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid granularity") +} + +func TestAdoptionServiceGetApis_Success_PaginationAndMapping(t *testing.T) { + var captured repositories.ApisQueryParams + repo := &adoptionRepoStub{ + getApisFunc: func(ctx context.Context, params repositories.ApisQueryParams) ([]repositories.ApiRow, int, error) { + captured = params + return []repositories.ApiRow{ + { + ApiId: "api-1", + ApiTitle: "API One", + Organisation: "Org 1", + IsCompliant: false, + TotalFailures: 2, + TotalWarnings: 1, + ViolatedRules: pq.StringArray{"ADR-001", "ADR-002"}, + LastLintDate: time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC), + }, + }, 205, nil + }, + } + svc := services.NewAdoptionService(repo) + + out, pagination, err := svc.GetApis(context.Background(), &models.AdoptionApisParams{ + AdoptionBaseParams: models.AdoptionBaseParams{AdrVersion: "ADR-2.0", StartDate: "2026-01-01", EndDate: "2026-01-31"}, + Compliant: boolPtr(false), + RuleCodes: strPtr("ADR-001, ADR-002"), + Page: 0, + PerPage: 999, + }) + require.NoError(t, err) + require.NotNil(t, pagination) + + assert.Equal(t, 1, captured.Page) + assert.Equal(t, 100, captured.PerPage) + if assert.NotNil(t, captured.Compliant) { + assert.False(t, *captured.Compliant) + } + assert.Equal(t, []string{"ADR-001", "ADR-002"}, captured.RuleCodes) + assert.Equal(t, time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), captured.EndDate) + + require.Len(t, out.Apis, 1) + assert.Equal(t, "api-1", out.Apis[0].ApiId) + assert.Equal(t, []string{"ADR-001", "ADR-002"}, out.Apis[0].ViolatedRules) + assert.Equal(t, 2, out.Apis[0].TotalViolations) + assert.Equal(t, 1, out.Apis[0].TotalWarnings) + + assert.Equal(t, 1, pagination.CurrentPage) + assert.Equal(t, 100, pagination.RecordsPerPage) + assert.Equal(t, 3, pagination.TotalPages) + assert.Equal(t, 205, pagination.TotalRecords) + if assert.NotNil(t, pagination.Next) { + assert.Equal(t, 2, *pagination.Next) + } + assert.Nil(t, pagination.Previous) +} + +func TestAdoptionServiceGetApis_RepoError(t *testing.T) { + repo := &adoptionRepoStub{ + getApisFunc: func(ctx context.Context, params repositories.ApisQueryParams) ([]repositories.ApiRow, int, error) { + return nil, 0, errors.New("db kapot") + }, + } + svc := services.NewAdoptionService(repo) + + out, pagination, err := svc.GetApis(context.Background(), &models.AdoptionApisParams{ + AdoptionBaseParams: models.AdoptionBaseParams{AdrVersion: "ADR-2.0", StartDate: "2026-01-01", EndDate: "2026-01-31"}, + }) + + require.Error(t, err) + assert.Nil(t, out) + assert.Nil(t, pagination) + assert.Contains(t, err.Error(), "db kapot") +} From 2564f42b7af0f6b6de4fbfbb4518d22a3a03510a Mon Sep 17 00:00:00 2001 From: pasibun Date: Tue, 24 Feb 2026 16:46:10 +0100 Subject: [PATCH 07/15] Add security requirements to adoption summary and rules endpoints --- pkg/api_client/routers.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pkg/api_client/routers.go b/pkg/api_client/routers.go index 70922b6..c242804 100644 --- a/pkg/api_client/routers.go +++ b/pkg/api_client/routers.go @@ -220,6 +220,13 @@ func NewRouter(apiVersion string, controller *handler.APIsAPIController, statsCo fizz.ID("getAdoptionSummary"), fizz.Summary("Get adoption summary"), fizz.Description("Returns overall adoption KPIs for the selected period"), + fizz.WithOptionalSecurity(), + fizz.Security(&openapi.SecurityRequirement{ + "apiKey": []string{}, + }), + fizz.Security(&openapi.SecurityRequirement{ + "clientCredentials": {"apis:read"}, + }), apiVersionHeaderOption, badRequestResponse, }, @@ -230,6 +237,13 @@ func NewRouter(apiVersion string, controller *handler.APIsAPIController, statsCo fizz.ID("getAdoptionRules"), fizz.Summary("Get adoption per rule"), fizz.Description("Returns adoption rate per ADR rule"), + fizz.WithOptionalSecurity(), + fizz.Security(&openapi.SecurityRequirement{ + "apiKey": []string{}, + }), + fizz.Security(&openapi.SecurityRequirement{ + "clientCredentials": {"apis:read"}, + }), apiVersionHeaderOption, badRequestResponse, }, @@ -240,6 +254,13 @@ func NewRouter(apiVersion string, controller *handler.APIsAPIController, statsCo fizz.ID("getAdoptionTimeline"), fizz.Summary("Get adoption timeline"), fizz.Description("Returns adoption over time for charts"), + fizz.WithOptionalSecurity(), + fizz.Security(&openapi.SecurityRequirement{ + "apiKey": []string{}, + }), + fizz.Security(&openapi.SecurityRequirement{ + "clientCredentials": {"apis:read"}, + }), apiVersionHeaderOption, badRequestResponse, }, @@ -250,6 +271,13 @@ func NewRouter(apiVersion string, controller *handler.APIsAPIController, statsCo fizz.ID("getAdoptionApis"), fizz.Summary("Get APIs with adoption status"), fizz.Description("Returns list of APIs with their compliance status"), + fizz.WithOptionalSecurity(), + fizz.Security(&openapi.SecurityRequirement{ + "apiKey": []string{}, + }), + fizz.Security(&openapi.SecurityRequirement{ + "clientCredentials": {"apis:read"}, + }), apiVersionHeaderOption, badRequestResponse, }, From f1e50141ea52e693ef00c4dbef0585548dc7c536 Mon Sep 17 00:00:00 2001 From: Matthijs Hovestad Date: Tue, 24 Feb 2026 20:34:35 +0100 Subject: [PATCH 08/15] Update pkg/api_client/repositories/adoption_repository.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/api_client/repositories/adoption_repository.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/api_client/repositories/adoption_repository.go b/pkg/api_client/repositories/adoption_repository.go index 31be11d..4b628fa 100644 --- a/pkg/api_client/repositories/adoption_repository.go +++ b/pkg/api_client/repositories/adoption_repository.go @@ -300,14 +300,14 @@ SELECT ( SELECT COUNT(DISTINCT api_id) FROM lint_results - WHERE created_at <= pr.period_end AND %s + WHERE created_at < pr.period_end + interval '1 day' AND %s ) AS total_apis, ( SELECT COUNT(DISTINCT sub.api_id) FROM ( SELECT DISTINCT ON (api_id) id, api_id FROM lint_results - WHERE created_at <= pr.period_end AND %s + WHERE created_at < pr.period_end + interval '1 day' AND %s ORDER BY api_id, created_at DESC ) sub WHERE NOT EXISTS ( From 43878c6ea313ae506d79c866482a3044b03e12b7 Mon Sep 17 00:00:00 2001 From: pasibun Date: Tue, 24 Feb 2026 20:42:44 +0100 Subject: [PATCH 09/15] Add security definitions to API endpoints and improve date range validation - Updated openapi.json to include security requirements for multiple API endpoints. - Modified parseDateRange function to validate that the start date is not after the end date and adjusted the end date to be an exclusive upper bound. - Added unit tests for date range validation to ensure correct behavior. --- api/openapi.json | 44 +++++++++++++++++++ .../repositories/adoption_repository.go | 28 ++++++------ .../repositories/adoption_repository_test.go | 2 +- pkg/api_client/services/adoption_service.go | 5 ++- .../adoption_service_internal_test.go | 26 +++++++++++ 5 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 pkg/api_client/services/adoption_service_internal_test.go diff --git a/api/openapi.json b/api/openapi.json index 5f1f6a3..9099c56 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -2536,6 +2536,17 @@ "get": { "description": "Returns list of APIs with their compliance status", "operationId": "getAdoptionApis", + "security": [ + {}, + { + "apiKey": [] + }, + { + "clientCredentials": [ + "apis:read" + ] + } + ], "parameters": [ { "in": "query", @@ -2640,6 +2651,17 @@ "get": { "description": "Returns adoption rate per ADR rule", "operationId": "getAdoptionRules", + "security": [ + {}, + { + "apiKey": [] + }, + { + "clientCredentials": [ + "apis:read" + ] + } + ], "parameters": [ { "in": "query", @@ -2728,6 +2750,17 @@ "get": { "description": "Returns overall adoption KPIs for the selected period", "operationId": "getAdoptionSummary", + "security": [ + {}, + { + "apiKey": [] + }, + { + "clientCredentials": [ + "apis:read" + ] + } + ], "parameters": [ { "in": "query", @@ -2800,6 +2833,17 @@ "get": { "description": "Returns adoption over time for charts", "operationId": "getAdoptionTimeline", + "security": [ + {}, + { + "apiKey": [] + }, + { + "clientCredentials": [ + "apis:read" + ] + } + ], "parameters": [ { "in": "query", diff --git a/pkg/api_client/repositories/adoption_repository.go b/pkg/api_client/repositories/adoption_repository.go index 4b628fa..4a4a4ad 100644 --- a/pkg/api_client/repositories/adoption_repository.go +++ b/pkg/api_client/repositories/adoption_repository.go @@ -80,11 +80,11 @@ func NewAdoptionRepository(db *gorm.DB) AdoptionRepository { } // latestResultsCTE builds the common CTE that finds the most recent lint result -// per API on or before endDate for a given adrVersion, with optional filters. +// per API before the exclusive endDate for a given adrVersion, with optional filters. func latestResultsCTE(params AdoptionQueryParams, selectCols string) (string, []interface{}) { var args []interface{} - where := "lr.created_at <= ? AND lr.adr_version = ?" + where := "lr.created_at < ? AND lr.adr_version = ?" args = append(args, params.EndDate, params.AdrVersion) if len(params.ApiIds) > 0 { @@ -129,8 +129,8 @@ FROM latest_results` return SummaryResult{}, 0, fmt.Errorf("summary query failed: %w", err) } - // Count total lint runs in the period - runsQuery := `SELECT COUNT(*) FROM lint_results WHERE created_at BETWEEN ? AND ? AND adr_version = ?` + // Count total lint runs in the period [startDate, endDate) + runsQuery := `SELECT COUNT(*) FROM lint_results WHERE created_at >= ? AND created_at < ? AND adr_version = ?` runsArgs := []interface{}{params.StartDate, params.EndDate, params.AdrVersion} if len(params.ApiIds) > 0 { @@ -244,10 +244,10 @@ func (r *adoptionRepository) GetTimeline(ctx context.Context, params TimelineQue "month": "YYYY-MM", } - periodEndExpr := map[string]string{ - "day": "ds.period_start", - "week": "ds.period_start + interval '6 days'", - "month": "(ds.period_start + interval '1 month' - interval '1 day')::date", + periodEndExclusiveExpr := map[string]string{ + "day": "ds.period_start + interval '1 day'", + "week": "ds.period_start + interval '1 week'", + "month": "ds.period_start + interval '1 month'", } // Build base filter for lint_results subqueries @@ -279,7 +279,7 @@ func (r *adoptionRepository) GetTimeline(ctx context.Context, params TimelineQue query := fmt.Sprintf(`WITH date_series AS ( SELECT generate_series( date_trunc('%s', ?::date), - date_trunc('%s', ?::date), + date_trunc('%s', (?::timestamp - interval '1 microsecond')::date), '1 %s'::interval )::date AS period_start ), @@ -290,7 +290,7 @@ period_rules AS ( SELECT ds.period_start, r.code AS rule_code, - %s AS period_end + %s AS period_end_exclusive FROM date_series ds CROSS JOIN rules r ) @@ -300,14 +300,16 @@ SELECT ( SELECT COUNT(DISTINCT api_id) FROM lint_results - WHERE created_at < pr.period_end + interval '1 day' AND %s +<<<<<<< HEAD + WHERE created_at < pr.period_end_exclusive AND %s ) AS total_apis, ( SELECT COUNT(DISTINCT sub.api_id) FROM ( SELECT DISTINCT ON (api_id) id, api_id FROM lint_results - WHERE created_at < pr.period_end + interval '1 day' AND %s +<<<<<<< HEAD + WHERE created_at < pr.period_end_exclusive AND %s ORDER BY api_id, created_at DESC ) sub WHERE NOT EXISTS ( @@ -320,7 +322,7 @@ FROM period_rules pr ORDER BY pr.rule_code, pr.period_start`, granularity, granularity, granularity, strings.Join(ruleCodePlaceholders, ","), - periodEndExpr[granularity], + periodEndExclusiveExpr[granularity], periodFormat[granularity], baseWhere, baseWhere, diff --git a/pkg/api_client/repositories/adoption_repository_test.go b/pkg/api_client/repositories/adoption_repository_test.go index 067257b..6b15593 100644 --- a/pkg/api_client/repositories/adoption_repository_test.go +++ b/pkg/api_client/repositories/adoption_repository_test.go @@ -31,7 +31,7 @@ func TestLatestResultsCTE_BuildsSQLWithFiltersAndArgsOrder(t *testing.T) { assert.Contains(t, cte, "WITH latest_results AS") assert.Contains(t, cte, "SELECT DISTINCT ON (lr.api_id) lr.api_id, lr.successes") - assert.Contains(t, cte, "lr.created_at <= ? AND lr.adr_version = ?") + assert.Contains(t, cte, "lr.created_at < ? AND lr.adr_version = ?") assert.Contains(t, cte, "lr.api_id IN (?,?)") assert.Contains(t, cte, "SELECT id FROM apis WHERE organisation_id = ?") assert.Contains(t, cte, "ORDER BY lr.api_id, lr.created_at DESC") diff --git a/pkg/api_client/services/adoption_service.go b/pkg/api_client/services/adoption_service.go index 19f240e..7fbcb09 100644 --- a/pkg/api_client/services/adoption_service.go +++ b/pkg/api_client/services/adoption_service.go @@ -257,7 +257,10 @@ func parseDateRange(startStr, endStr string) (time.Time, time.Time, error) { if err != nil { return time.Time{}, time.Time{}, fmt.Errorf("invalid endDate: %w", err) } - // Set end to end of day + if start.After(end) { + return time.Time{}, time.Time{}, fmt.Errorf("invalid date range: startDate must be on or before endDate") + } + // Return an exclusive upper bound at the start of the next day. end = end.Add(24 * time.Hour) return start, end, nil } diff --git a/pkg/api_client/services/adoption_service_internal_test.go b/pkg/api_client/services/adoption_service_internal_test.go new file mode 100644 index 0000000..47e9dc9 --- /dev/null +++ b/pkg/api_client/services/adoption_service_internal_test.go @@ -0,0 +1,26 @@ +package services + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDateRange_RejectsStartAfterEnd(t *testing.T) { + start, end, err := parseDateRange("2026-02-01", "2026-01-31") + + require.Error(t, err) + assert.True(t, start.IsZero()) + assert.True(t, end.IsZero()) + assert.Contains(t, err.Error(), "startDate must be on or before endDate") +} + +func TestParseDateRange_AllowsSameDay(t *testing.T) { + start, end, err := parseDateRange("2026-01-31", "2026-01-31") + + require.NoError(t, err) + assert.Equal(t, time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC), start) + assert.Equal(t, time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), end) +} From 8310cec1d9b2783fff305da3b02e7bdcf78028da Mon Sep 17 00:00:00 2001 From: Matthijs Hovestad Date: Tue, 24 Feb 2026 21:29:40 +0100 Subject: [PATCH 10/15] Update pkg/api_client/repositories/adoption_repository.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/api_client/repositories/adoption_repository.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/api_client/repositories/adoption_repository.go b/pkg/api_client/repositories/adoption_repository.go index 4a4a4ad..c5ac4d9 100644 --- a/pkg/api_client/repositories/adoption_repository.go +++ b/pkg/api_client/repositories/adoption_repository.go @@ -431,9 +431,9 @@ LEFT JOIN api_violations av ON av.api_id = lr.api_id` + whereClause // Data query with pagination offset := (params.Page - 1) * params.PerPage - dataQuery := baseQuery + whereClause + fmt.Sprintf(" ORDER BY a.title LIMIT %d OFFSET %d", params.PerPage, offset) + dataQuery := baseQuery + whereClause + " ORDER BY a.title LIMIT ? OFFSET ?" dataArgs := append(cteArgs, filterArgs...) - + dataArgs = append(dataArgs, params.PerPage, offset) var rows []ApiRow if err := r.db.WithContext(ctx).Raw(dataQuery, dataArgs...).Scan(&rows).Error; err != nil { return nil, 0, fmt.Errorf("apis query failed: %w", err) From ceca7334450cc0bd3b5f81bc02ad3e15b02d4028 Mon Sep 17 00:00:00 2001 From: pasibun Date: Tue, 24 Feb 2026 21:38:03 +0100 Subject: [PATCH 11/15] Remove merge conflict markers from adoption_repository.go --- pkg/api_client/repositories/adoption_repository.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/api_client/repositories/adoption_repository.go b/pkg/api_client/repositories/adoption_repository.go index c5ac4d9..9bb70dd 100644 --- a/pkg/api_client/repositories/adoption_repository.go +++ b/pkg/api_client/repositories/adoption_repository.go @@ -300,7 +300,6 @@ SELECT ( SELECT COUNT(DISTINCT api_id) FROM lint_results -<<<<<<< HEAD WHERE created_at < pr.period_end_exclusive AND %s ) AS total_apis, ( @@ -308,7 +307,6 @@ SELECT FROM ( SELECT DISTINCT ON (api_id) id, api_id FROM lint_results -<<<<<<< HEAD WHERE created_at < pr.period_end_exclusive AND %s ORDER BY api_id, created_at DESC ) sub From 1591fe0ffc70a52680097dbb0093d495394ef9e8 Mon Sep 17 00:00:00 2001 From: pasibun Date: Wed, 25 Feb 2026 08:02:10 +0100 Subject: [PATCH 12/15] Remove ADR adoption plan documentation --- adr-adoption-plan.md | 715 ------------------------------------------- 1 file changed, 715 deletions(-) delete mode 100644 adr-adoption-plan.md diff --git a/adr-adoption-plan.md b/adr-adoption-plan.md deleted file mode 100644 index 54804e4..0000000 --- a/adr-adoption-plan.md +++ /dev/null @@ -1,715 +0,0 @@ -# Implementatieplan: ADR Adoptie Statistieken API - -## 1. Context - -### 1.1 Doel -API endpoints voor het ontsluiten van ADR adoptiestatistieken in een dashboard. Het dashboard toont per ADR versie de adoptiegraad per regel over tijd, met diverse filtermogelijkheden. - -### 1.2 Database Schema -``` -lint_results -├── id (PK) -├── api_id (FK naar apis tabel) -├── successes (boolean) -├── failures (bigint) -├── warnings (bigint) -├── created_at (timestamptz) -└── adr_version (text) - -lint_messages -├── id (PK) -├── lint_result_id (FK) -├── line (bigint) -├── column (bigint) -├── severity (text) - "error" | "warning" -├── code (text) - bijv. "API-01", "API-05" -└── created_at (timestamptz) - -lint_message_infos -├── id (PK) -├── lint_message_id (FK) -├── message (text) -└── path (text) -``` - -### 1.3 Belangrijke Data-Eigenschap -Lint resultaten worden **alleen aangemaakt bij API wijzigingen**, niet periodiek. Voor tijdreeksen moet daarom de **laatst bekende status** per API worden gebruikt (point-in-time queries). - -### 1.4 Tabel- en Kolomnamen -GORM genereert tabelnamen automatisch (snake_case, meervoud) zonder `TableName()` overrides. De `search_path` is ingesteld op het schema, dus geen prefix nodig in queries. - -| Go field | DB kolom | Tabel | -|----------|----------|-------| -| `LintResult.Successes` | `successes` | `lint_results` | -| `LintResult.AdrVersion` | `adr_version` | `lint_results` | -| `Organisation.Label` | `label` | `organisations` | -| `Api.OrganisationID` | `organisation_id` | `apis` | - ---- - -## 2. API Specificatie - -### 2.1 Endpoints Overzicht - -| Endpoint | Methode | Doel | -|----------|---------|------| -| `/v1/statistics/summary` | GET | KPI's: totalen en overall adoptiegraad | -| `/v1/statistics/rules` | GET | Adoptie per ADR regel (snapshot) | -| `/v1/statistics/timeline` | GET | Adoptie over tijd per regel (voor grafieken) | -| `/v1/statistics/apis` | GET | Drill-down naar individuele API's | - -### 2.2 Gemeenschappelijke Query Parameters - -| Parameter | Type | Required | Beschrijving | -|-----------|------|----------|--------------| -| `adrVersion` | string | Ja | ADR versie (bijv. "1.0.0") | -| `startDate` | string (date) | Ja | Begin periode, ISO 8601 formaat (bijv. "2024-01-01") | -| `endDate` | string (date) | Ja | Eind periode, ISO 8601 formaat | -| `apiIds` | string | Nee | Comma-separated lijst van API ID's | -| `organisation` | string | Nee | Filter op organisatie URI | - ---- - -## 3. Endpoint Specificaties - -### 3.1 GET /v1/statistics/summary - -**Beschrijving**: Geeft algemene KPI's terug voor de geselecteerde periode. - -**Extra Parameters**: Geen - -**Response Model**: -```json -{ - "adrVersion": "1.0.0", - "period": { - "start": "2024-01-01", - "end": "2024-06-30" - }, - "totalApis": 150, - "compliantApis": 45, - "overallAdoptionRate": 30.0, - "totalLintRuns": 450 -} -``` - -**Velden**: -- `totalApis`: Aantal unieke API's met minimaal één lint result in of vóór de periode -- `compliantApis`: Aantal API's waarvan de laatst bekende status `successes = true` is -- `overallAdoptionRate`: `(compliantApis / totalApis) * 100`, afgerond op 1 decimaal -- `totalLintRuns`: Totaal aantal lint runs binnen de periode - ---- - -### 3.2 GET /v1/statistics/rules - -**Beschrijving**: Geeft adoptiegraad per ADR regel terug (snapshot op `endDate`). - -**Extra Parameters**: - -| Parameter | Type | Required | Beschrijving | -|-----------|------|----------|--------------| -| `ruleCodes` | string | Nee | Comma-separated regel codes (bijv. "API-01,API-05") | -| `severity` | string | Nee | Filter op severity: "error" of "warning" | - -**Response Model**: -```json -{ - "adrVersion": "1.0.0", - "period": { - "start": "2024-01-01", - "end": "2024-06-30" - }, - "totalApis": 150, - "rules": [ - { - "code": "API-01", - "severity": "error", - "violatingApis": 12, - "compliantApis": 138, - "adoptionRate": 92.0 - }, - { - "code": "API-05", - "severity": "error", - "violatingApis": 45, - "compliantApis": 105, - "adoptionRate": 70.0 - } - ] -} -``` - -**Berekening per regel**: -- Neem per API de laatst bekende lint result (op `endDate`) -- Tel voor die lint result of er een message is met `code = ` -- `violatingApis`: Aantal API's met minimaal één violation voor deze regel -- `compliantApis`: `totalApis - violatingApis` -- `adoptionRate`: `(compliantApis / totalApis) * 100` - ---- - -### 3.3 GET /v1/statistics/timeline - -**Beschrijving**: Geeft adoptie over tijd terug per regel voor grafieken. Altijd per regel: zonder `ruleCodes` worden alle regels teruggegeven, met `ruleCodes` alleen de opgegeven regels. - -**Extra Parameters**: - -| Parameter | Type | Required | Beschrijving | -|-----------|------|----------|--------------| -| `granularity` | string | Nee | "day", "week", of "month" (default: "month") | -| `ruleCodes` | string | Nee | Comma-separated regel codes (zonder = alle regels) | - -**Response Model**: -```json -{ - "adrVersion": "1.0.0", - "granularity": "month", - "series": [ - { - "type": "rule", - "ruleCode": "API-01", - "dataPoints": [ - { - "period": "2024-01", - "totalApis": 120, - "compliantApis": 100, - "adoptionRate": 83.3 - }, - { - "period": "2024-02", - "totalApis": 125, - "compliantApis": 110, - "adoptionRate": 88.0 - } - ] - }, - { - "type": "rule", - "ruleCode": "API-05", - "dataPoints": [ - { - "period": "2024-01", - "totalApis": 120, - "compliantApis": 60, - "adoptionRate": 50.0 - }, - { - "period": "2024-02", - "totalApis": 125, - "compliantApis": 70, - "adoptionRate": 56.0 - } - ] - } - ] -} -``` - -**Period formaat per granularity**: -- `day`: "2024-01-15" -- `week`: "2024-W03" (ISO week) -- `month`: "2024-01" - ---- - -### 3.4 GET /v1/statistics/apis - -**Beschrijving**: Lijst van API's met hun compliance status (voor drill-down in dashboard). - -**Extra Parameters**: - -| Parameter | Type | Required | Beschrijving | -|-----------|------|----------|--------------| -| `compliant` | boolean | Nee | Filter op compliance status | -| `ruleCodes` | string | Nee | Filter API's die deze specifieke regels schenden | -| `page` | integer | Nee | Pagina nummer (default: 1) | -| `perPage` | integer | Nee | Items per pagina (default: 20, max: 100) | - -**Response Model**: -```json -{ - "adrVersion": "1.0.0", - "period": { - "start": "2024-01-01", - "end": "2024-06-30" - }, - "apis": [ - { - "apiId": "abc-123", - "apiTitle": "Petstore API", - "organisation": "Gemeente Amsterdam", - "isCompliant": false, - "totalViolations": 3, - "totalWarnings": 5, - "violatedRules": ["API-01", "API-05", "API-12"], - "lastLintDate": "2024-06-28T14:30:00Z" - } - ] -} -``` - -**Response Headers** (bestaand patroon): -- `Total-Count`: Totaal aantal resultaten -- `Total-Pages`: Aantal pagina's -- `Per-Page`: Items per pagina -- `Current-Page`: Huidige pagina -- `Link`: RFC 5988 pagination links - ---- - -## 4. SQL Queries - -### 4.1 Point-in-Time Basis Query - -De kern van alle queries: de laatst bekende lint result per API op een bepaalde datum. - -```sql --- Laatst bekende lint result per API op :end_date -WITH latest_results AS ( - SELECT DISTINCT ON (lr.api_id) - lr.id, - lr.api_id, - lr.successes, - lr.failures, - lr.warnings, - lr.created_at, - lr.adr_version - FROM lint_results lr - WHERE lr.created_at <= :end_date - AND lr.adr_version = :adr_version - -- Optionele filters: - -- AND lr.api_id IN (:api_ids) - -- AND lr.api_id IN (SELECT id FROM apis WHERE organisation_id = :organisation) - ORDER BY lr.api_id, lr.created_at DESC -) -SELECT * FROM latest_results; -``` - -### 4.2 Summary Query - -```sql -WITH latest_results AS ( - SELECT DISTINCT ON (lr.api_id) - lr.api_id, - lr.successes - FROM lint_results lr - WHERE lr.created_at <= :end_date - AND lr.adr_version = :adr_version - ORDER BY lr.api_id, lr.created_at DESC -) -SELECT - COUNT(*) AS total_apis, - COUNT(*) FILTER (WHERE successes = true) AS compliant_apis, - COALESCE(ROUND( - (COUNT(*) FILTER (WHERE successes = true)::numeric / NULLIF(COUNT(*), 0)) * 100, - 1 - ), 0) AS adoption_rate -FROM latest_results; - --- Separaat: totaal aantal lint runs in de periode -SELECT COUNT(*) AS total_lint_runs -FROM lint_results -WHERE created_at BETWEEN :start_date AND :end_date - AND adr_version = :adr_version; -``` - -### 4.3 Rules Query - -```sql -WITH latest_results AS ( - SELECT DISTINCT ON (lr.api_id) - lr.id AS lint_result_id, - lr.api_id - FROM lint_results lr - WHERE lr.created_at <= :end_date - AND lr.adr_version = :adr_version - ORDER BY lr.api_id, lr.created_at DESC -), -total_count AS ( - SELECT COUNT(*) AS total_apis FROM latest_results -), -violations_per_rule AS ( - SELECT - lm.code, - lm.severity, - COUNT(DISTINCT lr.api_id) AS violating_apis - FROM latest_results lr - JOIN lint_messages lm ON lm.lint_result_id = lr.lint_result_id - WHERE 1=1 - -- Optioneel: AND lm.code IN (:rule_codes) - -- Optioneel: AND lm.severity = :severity - GROUP BY lm.code, lm.severity -) -SELECT - v.code, - v.severity, - v.violating_apis, - t.total_apis -FROM violations_per_rule v -CROSS JOIN total_count t -ORDER BY v.code; -``` - -### 4.4 Timeline Query (Per Regel) - -Altijd per regel. Zonder `ruleCodes` filter worden eerst alle regels opgehaald via een aparte query op de laatst bekende lint results. - -```sql -WITH date_series AS ( - SELECT generate_series( - date_trunc(:granularity, :start_date::date), - date_trunc(:granularity, :end_date::date), - ('1 ' || :granularity)::interval - )::date AS period_start -), -rules AS ( - SELECT unnest(ARRAY[:rule_codes]::text[]) AS code -), -period_rules AS ( - SELECT - ds.period_start, - r.code AS rule_code, - CASE :granularity - WHEN 'day' THEN ds.period_start - WHEN 'week' THEN ds.period_start + interval '6 days' - WHEN 'month' THEN (ds.period_start + interval '1 month' - interval '1 day')::date - END AS period_end - FROM date_series ds - CROSS JOIN rules r -) -SELECT - pr.rule_code, - TO_CHAR(pr.period_start, - CASE :granularity - WHEN 'day' THEN 'YYYY-MM-DD' - WHEN 'week' THEN 'IYYY-"W"IW' - WHEN 'month' THEN 'YYYY-MM' - END - ) AS period, - ( - SELECT COUNT(DISTINCT api_id) - FROM lint_results - WHERE created_at <= pr.period_end - AND adr_version = :adr_version - ) AS total_apis, - ( - SELECT COUNT(DISTINCT sub.api_id) - FROM ( - SELECT DISTINCT ON (api_id) id, api_id - FROM lint_results - WHERE created_at <= pr.period_end - AND adr_version = :adr_version - ORDER BY api_id, created_at DESC - ) sub - WHERE NOT EXISTS ( - SELECT 1 FROM lint_messages lm - WHERE lm.lint_result_id = sub.id - AND lm.code = pr.rule_code - ) - ) AS compliant_apis -FROM period_rules pr -ORDER BY pr.rule_code, pr.period_start; -``` - -### 4.5 APIs Query - -```sql -WITH latest_results AS ( - SELECT DISTINCT ON (lr.api_id) - lr.id AS lint_result_id, - lr.api_id, - lr.successes, - lr.failures, - lr.warnings, - lr.created_at - FROM lint_results lr - WHERE lr.created_at <= :end_date - AND lr.adr_version = :adr_version - ORDER BY lr.api_id, lr.created_at DESC -), -api_violations AS ( - SELECT - lr.api_id, - ARRAY_AGG(DISTINCT lm.code ORDER BY lm.code) AS violated_rules - FROM latest_results lr - JOIN lint_messages lm ON lm.lint_result_id = lr.lint_result_id - WHERE lm.severity = 'error' - GROUP BY lr.api_id -) -SELECT - lr.api_id, - a.title AS api_title, - COALESCE(o.label, '') AS organisation, - lr.successes AS is_compliant, - lr.failures AS total_violations, - lr.warnings AS total_warnings, - COALESCE(av.violated_rules, ARRAY[]::text[]) AS violated_rules, - lr.created_at AS last_lint_date -FROM latest_results lr -JOIN apis a ON a.id = lr.api_id -LEFT JOIN organisations o ON o.uri = a.organisation_id -LEFT JOIN api_violations av ON av.api_id = lr.api_id --- Optionele filters: --- WHERE lr.successes = :compliant --- WHERE av.violated_rules && ARRAY[:rule_codes]::text[] -ORDER BY a.title -LIMIT :per_page -OFFSET (:page - 1) * :per_page; -``` - ---- - -## 5. Go Implementatie - -### 5.1 Bestandsstructuur - -``` -pkg/api_client/ -├── models/ -│ └── adoption.go # Response models + query param structs -├── repositories/ -│ └── adoption_repository.go # Database queries (raw SQL via db.Raw) -├── services/ -│ └── adoption_service.go # Business logic, date parsing, response assembly -├── handler/ -│ └── statistics_handler.go # HTTP handlers (StatisticsController) -└── routers.go # Route registratie (wijzigen) -``` - -### 5.2 Models + Params (models/adoption.go) - -Params zitten in het `models` package (bestaand patroon, zie `list_apis_params.go`). - -```go -package models - -import "time" - -// Gemeenschappelijk -type Period struct { - Start string `json:"start"` - End string `json:"end"` -} - -// Summary endpoint -type AdoptionSummary struct { - AdrVersion string `json:"adrVersion"` - Period Period `json:"period"` - TotalApis int `json:"totalApis"` - CompliantApis int `json:"compliantApis"` - OverallAdoptionRate float64 `json:"overallAdoptionRate"` - TotalLintRuns int `json:"totalLintRuns"` -} - -// Rules endpoint -type AdoptionRules struct { - AdrVersion string `json:"adrVersion"` - Period Period `json:"period"` - TotalApis int `json:"totalApis"` - Rules []RuleAdoption `json:"rules"` -} - -type RuleAdoption struct { - Code string `json:"code"` - Severity string `json:"severity"` - ViolatingApis int `json:"violatingApis"` - CompliantApis int `json:"compliantApis"` - AdoptionRate float64 `json:"adoptionRate"` -} - -// Timeline endpoint -type AdoptionTimeline struct { - AdrVersion string `json:"adrVersion"` - Granularity string `json:"granularity"` - Series []TimelineSeries `json:"series"` -} - -type TimelineSeries struct { - Type string `json:"type"` - RuleCode string `json:"ruleCode,omitempty"` - DataPoints []TimelinePoint `json:"dataPoints"` -} - -type TimelinePoint struct { - Period string `json:"period"` - TotalApis int `json:"totalApis"` - CompliantApis int `json:"compliantApis"` - AdoptionRate float64 `json:"adoptionRate"` -} - -// APIs endpoint -type AdoptionApis struct { - AdrVersion string `json:"adrVersion"` - Period Period `json:"period"` - Apis []ApiAdoption `json:"apis"` -} - -type ApiAdoption struct { - ApiId string `json:"apiId"` - ApiTitle string `json:"apiTitle"` - Organisation string `json:"organisation"` - IsCompliant bool `json:"isCompliant"` - TotalViolations int `json:"totalViolations"` - TotalWarnings int `json:"totalWarnings"` - ViolatedRules []string `json:"violatedRules"` - LastLintDate time.Time `json:"lastLintDate"` -} - -// Query parameter structs -type AdoptionBaseParams struct { - AdrVersion string `query:"adrVersion" binding:"required"` - StartDate string `query:"startDate" binding:"required"` - EndDate string `query:"endDate" binding:"required"` - ApiIds *string `query:"apiIds"` - Organisation *string `query:"organisation"` -} - -type AdoptionRulesParams struct { - AdoptionBaseParams - RuleCodes *string `query:"ruleCodes"` - Severity *string `query:"severity"` -} - -type AdoptionTimelineParams struct { - AdoptionBaseParams - Granularity string `query:"granularity"` - RuleCodes *string `query:"ruleCodes"` -} - -type AdoptionApisParams struct { - AdoptionBaseParams - Compliant *bool `query:"compliant"` - RuleCodes *string `query:"ruleCodes"` - Page int `query:"page"` - PerPage int `query:"perPage"` -} -``` - -### 5.3 Repository (repositories/adoption_repository.go) - -Gebruikt `db.Raw()` met parameterized queries. Geeft ruwe resultaat-structs terug (geen response models). - -```go -type AdoptionRepository interface { - GetSummary(ctx context.Context, params AdoptionQueryParams) (SummaryResult, int, error) - GetRules(ctx context.Context, params AdoptionQueryParams) ([]RuleRow, int, error) - GetTimeline(ctx context.Context, params TimelineQueryParams) ([]TimelineRow, error) - GetApis(ctx context.Context, params ApisQueryParams) ([]ApiRow, int, error) -} -``` - -Dynamische SQL-opbouw: filters (`apiIds`, `organisation`, `ruleCodes`, `severity`, `compliant`) worden conditioneel toegepast via string builder + args slice. - -### 5.4 Service (services/adoption_service.go) - -```go -type AdoptionService struct { repo AdoptionRepository } -``` - -Verantwoordelijkheden: -- Parse date strings (`time.Parse("2006-01-02", ...)`) -- Split comma-separated params -- Validate granularity (default "month") -- Validate page/perPage defaults -- Repo aanroepen, response models assembleren -- `adoptionRate` berekenen: `math.Round(float64(compliant)/float64(total)*1000) / 10` - -### 5.5 Handler (handler/statistics_handler.go) - -```go -type StatisticsController struct { Service *services.AdoptionService } -``` - -Methodes volgen bestaand patroon (zie `handler/api_handler.go`): -- `GetSummary(ctx *gin.Context, p *models.AdoptionBaseParams) (*models.AdoptionSummary, error)` -- `GetRules(ctx *gin.Context, p *models.AdoptionRulesParams) (*models.AdoptionRules, error)` -- `GetTimeline(ctx *gin.Context, p *models.AdoptionTimelineParams) (*models.AdoptionTimeline, error)` -- `GetApis(ctx *gin.Context, p *models.AdoptionApisParams) (*models.AdoptionApis, error)` — zet pagination headers via `util.SetPaginationHeaders()` - -### 5.6 Route Registratie (routers.go wijzigen) - -`NewRouter` krijgt extra parameter `statsController *handler.StatisticsController`. - -```go -statsGroup := f.Group("/v1/statistics", "Statistics", "ADR adoption statistics endpoints") - -statsGroup.GET("/summary", - []fizz.OperationOption{ - fizz.ID("getAdoptionSummary"), - fizz.Summary("Get adoption summary"), - fizz.Description("Returns overall adoption KPIs for the selected period"), - apiVersionHeaderOption, - badRequestResponse, - }, - tonic.Handler(statsController.GetSummary, 200), -) -// ... analoog voor /rules, /timeline, /apis -``` - -### 5.7 DI Wiring (cmd/main.go wijzigen) - -```go -adoptionRepo := repositories.NewAdoptionRepository(db) -adoptionService := services.NewAdoptionService(adoptionRepo) -statsController := handler.NewStatisticsController(adoptionService) - -router := api.NewRouter(version, APIsAPIController, statsController) -``` - ---- - -## 6. Aandachtspunten - -### 6.1 Performance Overwegingen -- De timeline queries met subqueries per periode kunnen zwaar zijn bij grote datasets -- Overweeg een index op `(adr_version, api_id, created_at DESC)` voor de `lint_results` tabel -- Bij performance problemen: overweeg materialized views of een snapshot tabel - ---- - -## 7. Testen - -### 7.1 Unit Tests -- Test repository methodes met mock database -- Test berekening van adoptieRate (edge cases: 0 API's, 100% compliant, etc.) - -### 7.2 Integratie Tests -- Test point-in-time logica: API gevalideerd op dag X moet meetellen op dag X+n -- Test filters werken correct in combinatie -- Test pagination headers - -### 7.3 Handmatig Testen -```bash -# Summary -curl "http://localhost:1337/v1/statistics/summary?adrVersion=1.0.0&startDate=2024-01-01&endDate=2024-06-30" - -# Rules -curl "http://localhost:1337/v1/statistics/rules?adrVersion=1.0.0&startDate=2024-01-01&endDate=2024-06-30&severity=error" - -# Timeline (alle regels) -curl "http://localhost:1337/v1/statistics/timeline?adrVersion=1.0.0&startDate=2024-01-01&endDate=2024-06-30&granularity=month" - -# Timeline (specifieke regels) -curl "http://localhost:1337/v1/statistics/timeline?adrVersion=1.0.0&startDate=2024-01-01&endDate=2024-06-30&granularity=month&ruleCodes=API-01,API-05" - -# APIs -curl "http://localhost:1337/v1/statistics/apis?adrVersion=1.0.0&startDate=2024-01-01&endDate=2024-06-30&compliant=false&page=1&perPage=20" -``` - ---- - -## 8. Checklist - -- [x] `adr_version` veld toevoegen aan `LintResult` model -- [x] `models/adoption.go` aanmaken met response structs + param structs -- [x] `repositories/adoption_repository.go` aanmaken met queries -- [x] `services/adoption_service.go` aanmaken -- [x] `handler/statistics_handler.go` aanmaken -- [x] Routes registreren in `routers.go` -- [x] DI wiring in `cmd/main.go` -- [ ] `go build ./...` succesvol -- [ ] Database index toevoegen voor performance -- [ ] Unit tests schrijven -- [ ] Integratie tests schrijven -- [ ] OpenAPI spec genereren en valideren From 4b1577b3d1b68edabcf81a4239d50fddb976da64 Mon Sep 17 00:00:00 2001 From: pasibun Date: Wed, 25 Feb 2026 10:04:19 +0100 Subject: [PATCH 13/15] Enhance date parsing to support Dutch date format and improve error messaging --- pkg/api_client/services/adoption_service.go | 18 ++++++++++++++++-- .../services/adoption_service_internal_test.go | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/pkg/api_client/services/adoption_service.go b/pkg/api_client/services/adoption_service.go index 7fbcb09..5c4abe7 100644 --- a/pkg/api_client/services/adoption_service.go +++ b/pkg/api_client/services/adoption_service.go @@ -249,11 +249,11 @@ func (s *AdoptionService) GetApis(ctx context.Context, p *models.AdoptionApisPar } func parseDateRange(startStr, endStr string) (time.Time, time.Time, error) { - start, err := time.Parse("2006-01-02", startStr) + start, err := parseSupportedDate(startStr) if err != nil { return time.Time{}, time.Time{}, fmt.Errorf("invalid startDate: %w", err) } - end, err := time.Parse("2006-01-02", endStr) + end, err := parseSupportedDate(endStr) if err != nil { return time.Time{}, time.Time{}, fmt.Errorf("invalid endDate: %w", err) } @@ -265,6 +265,20 @@ func parseDateRange(startStr, endStr string) (time.Time, time.Time, error) { return start, end, nil } +func parseSupportedDate(raw string) (time.Time, error) { + value := strings.TrimSpace(raw) + layouts := []string{ + "2006-01-02", // ISO + "02-01-2006", // NL (dag-maand-jaar) + } + for _, layout := range layouts { + if t, err := time.Parse(layout, value); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("expected date format YYYY-MM-DD or DD-MM-YYYY") +} + func splitCSV(s *string) []string { if s == nil { return nil diff --git a/pkg/api_client/services/adoption_service_internal_test.go b/pkg/api_client/services/adoption_service_internal_test.go index 47e9dc9..b6a9bd0 100644 --- a/pkg/api_client/services/adoption_service_internal_test.go +++ b/pkg/api_client/services/adoption_service_internal_test.go @@ -24,3 +24,19 @@ func TestParseDateRange_AllowsSameDay(t *testing.T) { assert.Equal(t, time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC), start) assert.Equal(t, time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), end) } + +func TestParseDateRange_AllowsDutchDateFormat(t *testing.T) { + start, end, err := parseDateRange("01-01-2024", "31-01-2024") + + require.NoError(t, err) + assert.Equal(t, time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), start) + assert.Equal(t, time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), end) +} + +func TestParseDateRange_InvalidFormat_ShowsSupportedFormats(t *testing.T) { + _, _, err := parseDateRange("2024/01/01", "2024/01/31") + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid startDate") + assert.Contains(t, err.Error(), "YYYY-MM-DD or DD-MM-YYYY") +} From f58fa54fbc3242a8e4508b0c04060b126f1212e7 Mon Sep 17 00:00:00 2001 From: pasibun Date: Wed, 25 Feb 2026 11:23:50 +0100 Subject: [PATCH 14/15] Update adoption repository to use ruleset_version instead of adr_version in queries and tests --- pkg/api_client/repositories/adoption_repository.go | 6 +++--- pkg/api_client/repositories/adoption_repository_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/api_client/repositories/adoption_repository.go b/pkg/api_client/repositories/adoption_repository.go index 9bb70dd..fb3e0e6 100644 --- a/pkg/api_client/repositories/adoption_repository.go +++ b/pkg/api_client/repositories/adoption_repository.go @@ -84,7 +84,7 @@ func NewAdoptionRepository(db *gorm.DB) AdoptionRepository { func latestResultsCTE(params AdoptionQueryParams, selectCols string) (string, []interface{}) { var args []interface{} - where := "lr.created_at < ? AND lr.adr_version = ?" + where := "lr.created_at < ? AND lr.ruleset_version = ?" args = append(args, params.EndDate, params.AdrVersion) if len(params.ApiIds) > 0 { @@ -130,7 +130,7 @@ FROM latest_results` } // Count total lint runs in the period [startDate, endDate) - runsQuery := `SELECT COUNT(*) FROM lint_results WHERE created_at >= ? AND created_at < ? AND adr_version = ?` + runsQuery := `SELECT COUNT(*) FROM lint_results WHERE created_at >= ? AND created_at < ? AND ruleset_version = ?` runsArgs := []interface{}{params.StartDate, params.EndDate, params.AdrVersion} if len(params.ApiIds) > 0 { @@ -251,7 +251,7 @@ func (r *adoptionRepository) GetTimeline(ctx context.Context, params TimelineQue } // Build base filter for lint_results subqueries - baseWhere := "adr_version = ?" + baseWhere := "ruleset_version = ?" var baseArgs []interface{} baseArgs = append(baseArgs, params.AdrVersion) diff --git a/pkg/api_client/repositories/adoption_repository_test.go b/pkg/api_client/repositories/adoption_repository_test.go index 6b15593..368ad6e 100644 --- a/pkg/api_client/repositories/adoption_repository_test.go +++ b/pkg/api_client/repositories/adoption_repository_test.go @@ -31,7 +31,7 @@ func TestLatestResultsCTE_BuildsSQLWithFiltersAndArgsOrder(t *testing.T) { assert.Contains(t, cte, "WITH latest_results AS") assert.Contains(t, cte, "SELECT DISTINCT ON (lr.api_id) lr.api_id, lr.successes") - assert.Contains(t, cte, "lr.created_at < ? AND lr.adr_version = ?") + assert.Contains(t, cte, "lr.created_at < ? AND lr.ruleset_version = ?") assert.Contains(t, cte, "lr.api_id IN (?,?)") assert.Contains(t, cte, "SELECT id FROM apis WHERE organisation_id = ?") assert.Contains(t, cte, "ORDER BY lr.api_id, lr.created_at DESC") From 329b4eaaa93fdc7a683be8c1871400c54211ce81 Mon Sep 17 00:00:00 2001 From: pasibun Date: Wed, 25 Feb 2026 13:10:37 +0100 Subject: [PATCH 15/15] Add adoption-related schemas to OpenAPI specification --- api/openapi.json | 190 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/api/openapi.json b/api/openapi.json index 9099c56..92c495e 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -3117,6 +3117,196 @@ } } } + }, + "schemas": { + "ModelsAdoptionApis": { + "properties": { + "adrVersion": { + "type": "string" + }, + "apis": { + "items": { + "$ref": "#/components/schemas/ModelsApiAdoption" + }, + "type": "array" + }, + "period": { + "$ref": "#/components/schemas/ModelsPeriod" + } + }, + "type": "object" + }, + "ModelsAdoptionRules": { + "properties": { + "adrVersion": { + "type": "string" + }, + "period": { + "$ref": "#/components/schemas/ModelsPeriod" + }, + "rules": { + "items": { + "$ref": "#/components/schemas/ModelsRuleAdoption" + }, + "type": "array" + }, + "totalApis": { + "format": "int32", + "type": "integer" + } + }, + "type": "object" + }, + "ModelsAdoptionSummary": { + "properties": { + "adrVersion": { + "type": "string" + }, + "compliantApis": { + "format": "int32", + "type": "integer" + }, + "overallAdoptionRate": { + "format": "double", + "type": "number" + }, + "period": { + "$ref": "#/components/schemas/ModelsPeriod" + }, + "totalApis": { + "format": "int32", + "type": "integer" + }, + "totalLintRuns": { + "format": "int32", + "type": "integer" + } + }, + "type": "object" + }, + "ModelsAdoptionTimeline": { + "properties": { + "adrVersion": { + "type": "string" + }, + "granularity": { + "type": "string" + }, + "series": { + "items": { + "$ref": "#/components/schemas/ModelsTimelineSeries" + }, + "type": "array" + } + }, + "type": "object" + }, + "ModelsApiAdoption": { + "properties": { + "apiId": { + "type": "string" + }, + "apiTitle": { + "type": "string" + }, + "isCompliant": { + "type": "boolean" + }, + "lastLintDate": { + "format": "date-time", + "type": "string" + }, + "organisation": { + "type": "string" + }, + "totalViolations": { + "format": "int32", + "type": "integer" + }, + "totalWarnings": { + "format": "int32", + "type": "integer" + }, + "violatedRules": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "ModelsPeriod": { + "properties": { + "end": { + "type": "string" + }, + "start": { + "type": "string" + } + }, + "type": "object" + }, + "ModelsRuleAdoption": { + "properties": { + "adoptionRate": { + "format": "double", + "type": "number" + }, + "code": { + "type": "string" + }, + "compliantApis": { + "format": "int32", + "type": "integer" + }, + "severity": { + "type": "string" + }, + "violatingApis": { + "format": "int32", + "type": "integer" + } + }, + "type": "object" + }, + "ModelsTimelinePoint": { + "properties": { + "adoptionRate": { + "format": "double", + "type": "number" + }, + "compliantApis": { + "format": "int32", + "type": "integer" + }, + "period": { + "type": "string" + }, + "totalApis": { + "format": "int32", + "type": "integer" + } + }, + "type": "object" + }, + "ModelsTimelineSeries": { + "properties": { + "dataPoints": { + "items": { + "$ref": "#/components/schemas/ModelsTimelinePoint" + }, + "type": "array" + }, + "ruleCode": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "type": "object" + } } } }