diff --git a/acceptance/compose.yaml b/acceptance/compose.yaml index 50762fe..830d2e9 100644 --- a/acceptance/compose.yaml +++ b/acceptance/compose.yaml @@ -26,6 +26,7 @@ services: - PG_HOST=database - SUPER_USER_PASSWORD=sourcescore - API_KEY=test-key + - RATE_LIMIT_DISABLED=true ports: - "8080:8080" adminer: diff --git a/api/source-score.yaml b/api/source-score.yaml index 628bc0b..957626e 100644 --- a/api/source-score.yaml +++ b/api/source-score.yaml @@ -9,17 +9,17 @@ servers: description: Current host paths: - /ping: - get: - summary: Health check endpoint - description: Returns a simple pong response to verify the service is running - responses: - 200: - description: Service is healthy - content: - application/json: - schema: - $ref: '#/components/schemas/Pong' + # /ping: + # get: + # summary: Health check endpoint + # description: Returns a simple pong response to verify the service is running + # responses: + # 200: + # description: Service is healthy + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/Pong' /api/v1/sources: get: @@ -404,15 +404,15 @@ paths: components: schemas: - Pong: - type: object - required: - - pong - properties: - pong: - type: string - example: pong - description: Simple response string to confirm service health + # Pong: + # type: object + # required: + # - pong + # properties: + # pong: + # type: string + # example: pong + # description: Simple response string to confirm service health SourceInput: type: object diff --git a/cmd/app/main.go b/cmd/app/main.go index 51d4f5c..fcb202d 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -35,8 +35,8 @@ func main() { }), ) - // initialize the server - loggerOpts := api.GinServerOptions{ + // initialize the logger middleware + serverOpts := api.GinServerOptions{ Middlewares: []api.MiddlewareFunc{ // function to add request headers to log fields func(c *gin.Context) { @@ -75,23 +75,35 @@ func main() { server := gin.Default() + // Register liveness route + pingHandler := handlers.NewPingHandler() + server.GET("/ping", pingHandler.GetPing) + + // Register Swagger UI routes + swaggerHandler := handlers.NewSwaggerHandler(embed.OpenAPI) + server.GET("/swagger", swaggerHandler.ServeUI) + server.GET("/swagger/spec", swaggerHandler.ServeSpec) + + // Apply rate limiter middleware (10 requests per second, burst 20) + if os.Getenv("RATE_LIMIT_DISABLED") != "true" { + slog.Info("Rate limiter enabled") + // server.Use(middleware.RateLimiterMiddleware(10, 20)) + serverOpts.Middlewares = append(serverOpts.Middlewares, api.MiddlewareFunc(middleware.RateLimiterMiddleware(10, 20))) + } + // Secure with API key if the env var is set if key, ok := os.LookupEnv("API_KEY"); ok { slog.Info("API Key found, securing the API") - server.Use(middleware.APIKeyMiddleware(key)) + // server.Use(middleware.APIKeyMiddleware(key)) + serverOpts.Middlewares = append(serverOpts.Middlewares, api.MiddlewareFunc(middleware.APIKeyMiddleware(key))) } api.RegisterHandlersWithOptions( server, apiServer.NewRouter(context.Background(), srcSvc, claimSvc, proofSvc), - loggerOpts, + serverOpts, ) - // Register Swagger UI routes - swaggerHandler := handlers.NewSwaggerHandler(embed.OpenAPI) - server.GET("/swagger", swaggerHandler.ServeUI) - server.GET("/swagger/spec", swaggerHandler.ServeSpec) - err := server.Run() if err != nil { log.Fatalf("failed to start the server : %s\n", err.Error()) diff --git a/go.mod b/go.mod index c25e1bd..ef1d8e1 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/oapi-codegen/runtime v1.3.0 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 + golang.org/x/time v0.15.0 ) require ( diff --git a/go.sum b/go.sum index 4111d62..150b7bf 100644 --- a/go.sum +++ b/go.sum @@ -989,6 +989,8 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/pkg/api/server.gen.go b/pkg/api/server.gen.go index f22b6e7..69ae3ab 100644 --- a/pkg/api/server.gen.go +++ b/pkg/api/server.gen.go @@ -11,91 +11,151 @@ import ( "github.com/oapi-codegen/runtime" ) -// Claim defines model for Claim. +// Claim Complete claim entity with verification status type Claim struct { - Checked bool `binding:"required" json:"checked"` + // Checked Indicates whether the claim has been verified + Checked bool `binding:"required" json:"checked"` + + // SourceUriDigest SHA-256 hash of the parent source URI SourceUriDigest string `binding:"required" json:"sourceUriDigest"` - Summary string `binding:"required" json:"summary"` - Title string `binding:"required" json:"title"` - Uri string `binding:"required" json:"uri"` - UriDigest string `binding:"required" gorm:"primaryKey" json:"uriDigest"` - Validity bool `binding:"required" json:"validity"` + + // Summary Detailed description of the claim + Summary string `binding:"required" json:"summary"` + + // Title Short title of the claim + Title string `binding:"required" json:"title"` + + // Uri Unique HTTPS URL of the claim + Uri string `binding:"required" json:"uri"` + + // UriDigest SHA-256 hash of the URI, used as primary key + UriDigest string `binding:"required" gorm:"primaryKey" json:"uriDigest"` + + // Validity Indicates whether the claim is valid (true) or invalid (false) based on proof analysis + Validity bool `binding:"required" json:"validity"` } -// ClaimInput defines model for ClaimInput. +// ClaimInput Input schema for creating a new claim type ClaimInput struct { + // SourceUriDigest SHA-256 hash of the parent source URI SourceUriDigest string `binding:"required" json:"sourceUriDigest" validate:"nonempty"` - Summary string `binding:"required" json:"summary" validate:"nonempty"` - Title string `binding:"required" json:"title" validate:"nonempty"` - Uri string `binding:"required" json:"uri" validate:"httpsurl"` + + // Summary Detailed description of the claim + Summary string `binding:"required" json:"summary" validate:"nonempty"` + + // Title Short title of the claim + Title string `binding:"required" json:"title" validate:"nonempty"` + + // Uri Unique HTTPS URL identifying the claim + Uri string `binding:"required" json:"uri" validate:"httpsurl"` } -// ClaimPatchInput defines model for ClaimPatchInput. +// ClaimPatchInput Input schema for partially updating a claim type ClaimPatchInput struct { + // Summary Updated detailed description Summary *string `json:"summary" validate:"omitnil,nonempty"` - Title *string `json:"title" validate:"omitnil,nonempty"` + + // Title Updated short title + Title *string `json:"title" validate:"omitnil,nonempty"` } -// ClaimVerification defines model for ClaimVerification. +// ClaimVerification Input schema for manually verifying a claim type ClaimVerification struct { + // Validity Whether the claim is valid (true) or invalid (false) Validity *bool `binding:"required" json:"validity,omitempty" validate:"nonempty"` } -// CreateSourceResponse defines model for CreateSourceResponse. +// CreateSourceResponse Response after successfully creating a source type CreateSourceResponse struct { + // UriDigest SHA-256 hash of the source URI UriDigest string `binding:"required" json:"uriDigest"` } -// Pong defines model for Pong. -type Pong struct { - Pong string `json:"pong"` -} - -// Proof defines model for Proof. +// Proof Complete proof entity type Proof struct { + // ClaimUriDigest SHA-256 hash of the claim URI ClaimUriDigest string `binding:"required" json:"claimUriDigest"` - ReviewedBy string `binding:"required" json:"reviewedBy"` - SupportsClaim bool `binding:"required" json:"supportsClaim"` - Uri string `binding:"required" json:"uri"` - UriDigest string `binding:"required" gorm:"primaryKey" json:"uriDigest"` + + // ReviewedBy Identifier of the reviewer + ReviewedBy string `binding:"required" json:"reviewedBy"` + + // SupportsClaim Whether this proof supports (true) or refutes (false) the claim + SupportsClaim bool `binding:"required" json:"supportsClaim"` + + // Uri Unique HTTPS URL of the proof source + Uri string `binding:"required" json:"uri"` + + // UriDigest SHA-256 hash of the URI, used as primary key + UriDigest string `binding:"required" gorm:"primaryKey" json:"uriDigest"` } -// ProofInput defines model for ProofInput. +// ProofInput Input schema for creating a new proof type ProofInput struct { + // ClaimUriDigest SHA-256 hash of the claim URI being evaluated (no spaces allowed) ClaimUriDigest string `binding:"required" json:"claimUriDigest" validate:"nonempty,nospace"` - ReviewedBy string `binding:"required" json:"reviewedBy" validate:"nonempty,nospace"` - SupportsClaim *bool `binding:"required" json:"supportsClaim,omitempty" validate:"nonempty"` - Uri string `binding:"required" json:"uri" validate:"httpsurl"` + + // ReviewedBy Identifier of the reviewer who evaluated this proof (no spaces allowed) + ReviewedBy string `binding:"required" json:"reviewedBy" validate:"nonempty,nospace"` + + // SupportsClaim Whether this proof supports (true) or refutes (false) the claim + SupportsClaim *bool `binding:"required" json:"supportsClaim,omitempty" validate:"nonempty"` + + // Uri Unique HTTPS URL of the proof source + Uri string `binding:"required" json:"uri" validate:"httpsurl"` } -// ProofPatchInput defines model for ProofPatchInput. +// ProofPatchInput Input schema for updating proof reviewer type ProofPatchInput struct { + // ReviewedBy Updated reviewer identifier (no spaces allowed) ReviewedBy string `binding:"required" json:"reviewedBy" validate:"nonempty,nospace"` } -// Source defines model for Source. +// Source Complete source entity with calculated credibility score type Source struct { - Name string `binding:"required" json:"name"` - Score float64 `binding:"required" json:"score"` - Summary string `binding:"required" json:"summary"` - Tags string `binding:"required" json:"tags"` - Uri string `binding:"required" json:"uri"` - UriDigest string `binding:"required" gorm:"primaryKey" json:"uriDigest"` + // Name Display name of the source + Name string `binding:"required" json:"name"` + + // Score Credibility score (0.0 to 1.0) calculated as ratio of valid to total verified claims + Score float64 `binding:"required" json:"score"` + + // Summary Brief description of the source + Summary string `binding:"required" json:"summary"` + + // Tags Comma-separated categorization tags + Tags string `binding:"required" json:"tags"` + + // Uri Unique HTTPS URL of the source + Uri string `binding:"required" json:"uri"` + + // UriDigest SHA-256 hash of the URI, used as primary key + UriDigest string `binding:"required" gorm:"primaryKey" json:"uriDigest"` } -// SourceInput defines model for SourceInput. +// SourceInput Input schema for creating a new source type SourceInput struct { - Name string `binding:"required" json:"name" validate:"nonempty"` + // Name Display name of the information source + Name string `binding:"required" json:"name" validate:"nonempty"` + + // Summary Brief description of the source Summary string `binding:"required" json:"summary" validate:"nonempty"` - Tags string `binding:"required" json:"tags" validate:"nonempty,nospace"` - Uri string `binding:"required" json:"uri" validate:"httpsurl"` + + // Tags Comma-separated tags for categorization (no spaces allowed) + Tags string `binding:"required" json:"tags" validate:"nonempty,nospace"` + + // Uri Unique HTTPS URL identifying the source + Uri string `binding:"required" json:"uri" validate:"httpsurl"` } -// SourcePatchInput defines model for SourcePatchInput. +// SourcePatchInput Input schema for partially updating a source type SourcePatchInput struct { - Name *string `json:"name" validate:"omitnil,nonempty"` + // Name Updated display name + Name *string `json:"name" validate:"omitnil,nonempty"` + + // Summary Updated description Summary *string `json:"summary" validate:"omitnil,nonempty"` - Tags *string `json:"tags" validate:"omitnil,nospace,nonempty"` + + // Tags Updated comma-separated tags (no spaces allowed) + Tags *string `json:"tags" validate:"omitnil,nospace,nonempty"` } // PostClaimJSONRequestBody defines body for PostClaim for application/json ContentType. @@ -121,63 +181,60 @@ type PatchSourceJSONRequestBody = SourcePatchInput // ServerInterface represents all server handlers. type ServerInterface interface { - + // Create a new claim // (POST /api/v1/claim) PostClaim(c *gin.Context) - + // Delete a claim // (DELETE /api/v1/claim/{uriDigest}) DeleteClaim(c *gin.Context, uriDigest string) - + // Get claim by URI digest // (GET /api/v1/claim/{uriDigest}) GetClaim(c *gin.Context, uriDigest string) - + // Update claim fields // (PATCH /api/v1/claim/{uriDigest}) PatchClaim(c *gin.Context, uriDigest string) - + // Verify a single claim // (POST /api/v1/claim/{uriDigest}) VerifyClaim(c *gin.Context, uriDigest string) - + // Get all claims // (GET /api/v1/claims) GetClaims(c *gin.Context) - + // Verify all claims // (POST /api/v1/claims/verify) VerifyAllClaims(c *gin.Context) - + // Create a new proof // (POST /api/v1/proof) PostProof(c *gin.Context) - + // Delete a proof // (DELETE /api/v1/proof/{uriDigest}) DeleteProof(c *gin.Context, uriDigest string) - + // Get proof by URI digest // (GET /api/v1/proof/{uriDigest}) GetProof(c *gin.Context, uriDigest string) - + // Update proof reviewer // (PATCH /api/v1/proof/{uriDigest}) PatchProof(c *gin.Context, uriDigest string) - + // Get all proofs // (GET /api/v1/proofs) GetProofs(c *gin.Context) - + // Create a new source // (POST /api/v1/source) PostSource(c *gin.Context) - + // Delete a source // (DELETE /api/v1/source/{uriDigest}) DeleteSource(c *gin.Context, uriDigest string) - + // Get source by URI digest // (GET /api/v1/source/{uriDigest}) GetSource(c *gin.Context, uriDigest string) - + // Update source fields // (PATCH /api/v1/source/{uriDigest}) PatchSource(c *gin.Context, uriDigest string) - + // Get all sources // (GET /api/v1/sources) GetSources(c *gin.Context) - + // Update all source scores // (POST /api/v1/sources/scores) UpdateAllScores(c *gin.Context) - - // (GET /ping) - GetPing(c *gin.Context) } // ServerInterfaceWrapper converts contexts to parameters. @@ -533,19 +590,6 @@ func (siw *ServerInterfaceWrapper) UpdateAllScores(c *gin.Context) { siw.Handler.UpdateAllScores(c) } -// GetPing operation middleware -func (siw *ServerInterfaceWrapper) GetPing(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetPing(c) -} - // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -591,5 +635,4 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.PATCH(options.BaseURL+"/api/v1/source/:uriDigest", wrapper.PatchSource) router.GET(options.BaseURL+"/api/v1/sources", wrapper.GetSources) router.POST(options.BaseURL+"/api/v1/sources/scores", wrapper.UpdateAllScores) - router.GET(options.BaseURL+"/ping", wrapper.GetPing) } diff --git a/pkg/handlers/ping.go b/pkg/handlers/ping.go index dd17a3e..b2dc9c1 100644 --- a/pkg/handlers/ping.go +++ b/pkg/handlers/ping.go @@ -1,7 +1,9 @@ package handlers import ( - "context" + "net/http" + + "github.com/gin-gonic/gin" ) type PingHandler struct { @@ -14,6 +16,6 @@ func NewPingHandler() *PingHandler { } } -func (ph PingHandler) GetPing(ctx context.Context) string { - return ph.message +func (ph PingHandler) GetPing(ctx *gin.Context) { + ctx.JSON(http.StatusOK, gin.H{"data": ph.message}) } diff --git a/pkg/http/router.go b/pkg/http/router.go index af1c067..7d64512 100644 --- a/pkg/http/router.go +++ b/pkg/http/router.go @@ -2,7 +2,6 @@ package http import ( "context" - "net/http" "source-score/pkg/domain/claim" "source-score/pkg/domain/proof" "source-score/pkg/domain/source" @@ -64,12 +63,6 @@ func (r *router) GetClaim(ctx *gin.Context, uriDigest string) { r.claimHandler.GetClaimByUriDigest(ctx, uriDigest) } -func (r *router) GetPing(ctx *gin.Context) { - message := r.pingHandler.GetPing(ctx) - - ctx.JSON(http.StatusOK, gin.H{"data": message}) -} - func (r *router) DeleteClaim(ctx *gin.Context, uriDigest string) { r.claimHandler.DeleteClaimByUriDigest(ctx, uriDigest) } diff --git a/pkg/middleware/ratelimiter.go b/pkg/middleware/ratelimiter.go new file mode 100644 index 0000000..d2570bd --- /dev/null +++ b/pkg/middleware/ratelimiter.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +func RateLimiterMiddleware(rps int, burst int) gin.HandlerFunc { + limiter := rate.NewLimiter(rate.Limit(rps), burst) + + return func(c *gin.Context) { + if !limiter.Allow() { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "rate limit exceeded", + }) + return + } + c.Next() + } +}