diff --git a/api/openapi.json b/api/openapi.json index 9e703a5..92c495e 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -41,6 +41,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": { @@ -1191,231 +1195,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": { - "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": [ { @@ -2752,91 +2531,486 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "name": "X-Api-Key", - "in": "header", - "description": "Read-only API keys for public endpoints can be requested at https://apis.developer.overheid.nl/apis/key-aanvragen." - }, - "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" - } - } - } }, - "responses": { - "400": { - "headers": { - "API-Version": { - "description": "Semver of this API", + "/statistics/apis": { + "get": { + "description": "Returns list of APIs with their compliance status", + "operationId": "getAdoptionApis", + "security": [ + {}, + { + "apiKey": [] + }, + { + "clientCredentials": [ + "apis:read" + ] + } + ], + "parameters": [ + { + "in": "query", + "name": "adrVersion", "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" - } + "type": "string" } - } - }, - "description": "Bad request", - "content": { - "application/problem+json": { + }, + { + "in": "query", + "name": "apiIds", "schema": { - "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", + "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", + "security": [ + {}, + { + "apiKey": [] + }, + { + "clientCredentials": [ + "apis:read" + ] + } + ], + "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", + "security": [ + {}, + { + "apiKey": [] + }, + { + "clientCredentials": [ + "apis:read" + ] + } + ], + "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", + "security": [ + {}, + { + "apiKey": [] + }, + { + "clientCredentials": [ + "apis:read" + ] + } + ], + "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", + "description": "Read-only API keys for public endpoints can be requested at https://apis.developer.overheid.nl/apis/key-aanvragen." + }, + "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" + } + } + } + }, + "responses": { + "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": { + "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" }, @@ -2943,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" + } } } } 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/api_handler.go b/pkg/api_client/handler/api_handler.go index 9746edb..bd5d46d 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/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/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/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/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..fb3e0e6 --- /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 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.ruleset_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 [startDate, endDate) + 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 { + 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", + } + + 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 + baseWhere := "ruleset_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', (?::timestamp - interval '1 microsecond')::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_exclusive + 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_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_exclusive 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, ","), + periodEndExclusiveExpr[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 + " 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) + } + + for i := range rows { + if rows[i].ViolatedRules == nil { + rows[i].ViolatedRules = pq.StringArray{} + } + } + + return rows, totalCount, nil +} 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..368ad6e --- /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.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") + + 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/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 1018349..c242804 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() @@ -182,21 +182,6 @@ func NewRouter(apiVersion string, controller *handler.APIsAPIController) *fizz.F 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"), @@ -228,6 +213,77 @@ 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"), + fizz.WithOptionalSecurity(), + fizz.Security(&openapi.SecurityRequirement{ + "apiKey": []string{}, + }), + fizz.Security(&openapi.SecurityRequirement{ + "clientCredentials": {"apis:read"}, + }), + 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"), + fizz.WithOptionalSecurity(), + fizz.Security(&openapi.SecurityRequirement{ + "apiKey": []string{}, + }), + fizz.Security(&openapi.SecurityRequirement{ + "clientCredentials": {"apis:read"}, + }), + 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"), + fizz.WithOptionalSecurity(), + fizz.Security(&openapi.SecurityRequirement{ + "apiKey": []string{}, + }), + fizz.Security(&openapi.SecurityRequirement{ + "clientCredentials": {"apis:read"}, + }), + 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"), + fizz.WithOptionalSecurity(), + fizz.Security(&openapi.SecurityRequirement{ + "apiKey": []string{}, + }), + fizz.Security(&openapi.SecurityRequirement{ + "clientCredentials": {"apis:read"}, + }), + 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..5c4abe7 --- /dev/null +++ b/pkg/api_client/services/adoption_service.go @@ -0,0 +1,320 @@ +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 + } + + // Validate severity according to adr-adoption-plan.md: only "error" or "warning" are allowed. + 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{ + AdrVersion: p.AdrVersion, + EndDate: endDate, + ApiIds: splitCSV(p.ApiIds), + Organisation: trimOptional(p.Organisation), + RuleCodes: splitCSV(p.RuleCodes), + Severity: 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 := parseSupportedDate(startStr) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid startDate: %w", err) + } + end, err := parseSupportedDate(endStr) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid endDate: %w", err) + } + 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 +} + +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 + } + 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 +} 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..b6a9bd0 --- /dev/null +++ b/pkg/api_client/services/adoption_service_internal_test.go @@ -0,0 +1,42 @@ +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) +} + +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") +} 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") +} 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) }