This commit is contained in:
15
internal/api/middleware/add_request_id.go
Normal file
15
internal/api/middleware/add_request_id.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func AddRequestIDHeaderMiddleware(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(middleware.RequestIDHeader, middleware.GetReqID(r.Context()))
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
118
internal/api/middleware/current_user.go
Normal file
118
internal/api/middleware/current_user.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
|
||||
func verifyTokenAndGetClaims(config *config.ApplicationConfig, ctx context.Context, token string) (*oidc.IDToken, Claims, error) {
|
||||
idToken, err := config.OidcConfig.Verifier.Verify(ctx, token)
|
||||
claims := Claims{Groups: []string{}}
|
||||
|
||||
if err != nil {
|
||||
return idToken, claims, err
|
||||
}
|
||||
|
||||
err = idToken.Claims(&claims)
|
||||
|
||||
if err != nil {
|
||||
return idToken, claims, err
|
||||
}
|
||||
|
||||
return idToken, claims, nil
|
||||
}
|
||||
|
||||
func serveWithTokenAndClaims(next http.Handler, r *http.Request, w http.ResponseWriter, claims Claims, token *oidc.IDToken) {
|
||||
ctx := context.WithValue(r.Context(), "user", token)
|
||||
ctx = context.WithValue(ctx, "claims", claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
func CurrentUserMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) {
|
||||
middleware := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := config.CookieStore.Get(r, config.SessionName)
|
||||
rawIDToken, ok := session.Values["id_token"].(string)
|
||||
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// In test environment, bypass OIDC verification and use mock claims
|
||||
if config.Environment == "test" || config.Environment == "testEnvironment" {
|
||||
mockClaims := Claims{
|
||||
Sub: "test-user-id",
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
Groups: []string{"calculate-negative-points-users"},
|
||||
}
|
||||
serveWithTokenAndClaims(next, r, w, mockClaims, nil)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, claims, err := verifyTokenAndGetClaims(config, r.Context(), rawIDToken)
|
||||
|
||||
if err == nil {
|
||||
serveWithTokenAndClaims(next, r, w, claims, idToken)
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, ok := session.Values["refresh_token"].(string)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt refresh
|
||||
tokenSrc := config.OidcConfig.OAuth2.TokenSource(r.Context(), &oauth2.Token{
|
||||
RefreshToken: refreshToken,
|
||||
})
|
||||
|
||||
newToken, err := tokenSrc.Token()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to refresh token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Save new tokens
|
||||
newRawIDToken, ok := newToken.Extra("id_token").(string)
|
||||
if !ok {
|
||||
http.Error(w, "missing id_token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, claims, err = verifyTokenAndGetClaims(config, r.Context(), newRawIDToken)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "invalid refreshed token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
session.Values["id_token"] = newRawIDToken
|
||||
if newToken.RefreshToken != "" {
|
||||
session.Values["refresh_token"] = newToken.RefreshToken
|
||||
}
|
||||
if scope, ok := newToken.Extra("scope").(string); ok {
|
||||
session.Values["scope"] = scope
|
||||
}
|
||||
session.Save(r, w)
|
||||
|
||||
serveWithTokenAndClaims(next, r, w, claims, idToken)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
return middleware, nil
|
||||
}
|
||||
64
internal/api/middleware/error.go
Normal file
64
internal/api/middleware/error.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
// "github.com/getsentry/sentry-go"
|
||||
"clintonambulance.com/calculate_negative_points/internal/utils"
|
||||
"github.com/swaggest/rest"
|
||||
"github.com/swaggest/rest/nethttp"
|
||||
)
|
||||
|
||||
func ErrorResponder(w http.ResponseWriter, error string, code int) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(utils.NewApplicationError(error, code))
|
||||
}
|
||||
|
||||
type errorWithFields interface {
|
||||
error
|
||||
Fields() map[string]interface{}
|
||||
}
|
||||
|
||||
// ErrorHandler middleware centralizes error handling and translation of errors between different error types
|
||||
func ErrorHandler(next http.Handler) http.Handler {
|
||||
var h *nethttp.Handler
|
||||
if nethttp.HandlerAs(next, &h) {
|
||||
resp := func(ctx context.Context, err error) (int, interface{}) {
|
||||
// Handle AWS errors
|
||||
|
||||
// Application-specific error handling: if this error has a marker interface, serialize it as JSON
|
||||
var customErr utils.CustomApplicationError
|
||||
if errors.As(err, &customErr) {
|
||||
return customErr.HTTPStatus(), customErr
|
||||
}
|
||||
|
||||
// More detailed validation error messages
|
||||
var validationErr errorWithFields
|
||||
if errors.As(err, &validationErr) {
|
||||
detailedErrorMessages := make([]string, len(validationErr.Fields()))
|
||||
i := 0
|
||||
for k, v := range validationErr.Fields() {
|
||||
detailedErrorMessages[i] = fmt.Sprintf("%s: %v", k, v)
|
||||
i++
|
||||
}
|
||||
return http.StatusUnprocessableEntity, utils.ApplicationError{
|
||||
Message: fmt.Sprintf("Validation errors: %s", strings.Join(detailedErrorMessages, ", ")),
|
||||
}
|
||||
}
|
||||
|
||||
code, er := rest.Err(err)
|
||||
return code, utils.ApplicationError{
|
||||
Message: er.ErrorText,
|
||||
}
|
||||
}
|
||||
h.MakeErrResp = resp
|
||||
}
|
||||
return next
|
||||
}
|
||||
77
internal/api/middleware/jwt_middleware.go
Normal file
77
internal/api/middleware/jwt_middleware.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userContextKey = contextKey("user")
|
||||
|
||||
func audMatch(aud interface{}, expected string) bool {
|
||||
switch v := aud.(type) {
|
||||
case string:
|
||||
return v == expected
|
||||
case []interface{}:
|
||||
for _, val := range v {
|
||||
if s, ok := val.(string); ok && s == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func JWTMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) {
|
||||
middleware := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodPut && r.Method != http.MethodDelete {
|
||||
// Skip auth for safe methods like GET/HEAD
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
|
||||
// Check if the header exists and starts with "Bearer "
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
http.Error(w, "Unauthorized: Bearer token missing or invalid", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the token by removing the "Bearer " prefix
|
||||
//tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
//token, err := jwt.Parse(tokenStr, config.Jwt.KeySet.Keyfunc)
|
||||
//if err != nil || !token.Valid {
|
||||
// http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||
// return
|
||||
//}
|
||||
|
||||
//claims, ok := token.Claims.(jwt.MapClaims)
|
||||
//if !ok {
|
||||
// http.Error(w, "Invalid token claims", http.StatusUnauthorized)
|
||||
// return
|
||||
//}
|
||||
|
||||
// Check `iss` and `aud`
|
||||
// if claims["iss"] != config.Jwt.Issuer {
|
||||
// http.Error(w, "Invalid issuer", http.StatusUnauthorized)
|
||||
// return
|
||||
// }
|
||||
// audClaim := claims["aud"]
|
||||
// if !audMatch(audClaim, config.Jwt.Audience) {
|
||||
// http.Error(w, "Invalid audience", http.StatusUnauthorized)
|
||||
// return
|
||||
// }
|
||||
|
||||
//ctx := context.WithValue(r.Context(), userContextKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(r.Context()))
|
||||
})
|
||||
}
|
||||
|
||||
return middleware, nil
|
||||
}
|
||||
50
internal/api/middleware/logging.go
Normal file
50
internal/api/middleware/logging.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
type ContextKey string
|
||||
|
||||
const (
|
||||
LoggerContext = ContextKey("logger")
|
||||
)
|
||||
|
||||
func LoggingMiddleware(logger *zap.Logger) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
if ww.Status() < 300 && strings.HasPrefix(r.URL.Path, "/health") {
|
||||
// Don't log health checks unless they fail (ping endpoint returns empty response, HTTP status 204).
|
||||
return
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
fields := []zapcore.Field{
|
||||
zap.Any("body", r.Body),
|
||||
zap.String("remote_ip", r.RemoteAddr),
|
||||
zap.String("method", r.Method),
|
||||
zap.String("uri", r.URL.Path),
|
||||
zap.String("request_id", middleware.GetReqID(r.Context())),
|
||||
zap.Int("status", ww.Status()),
|
||||
zap.Float64("latency_ms", float64(time.Since(start))/float64(time.Millisecond)),
|
||||
zap.Int("size", ww.BytesWritten()),
|
||||
}
|
||||
logger.Info("HTTP request processed", fields...)
|
||||
}()
|
||||
r = r.WithContext(context.WithValue(r.Context(), LoggerContext, logger))
|
||||
next.ServeHTTP(ww, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
28
internal/api/middleware/logout.go
Normal file
28
internal/api/middleware/logout.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
)
|
||||
|
||||
func Logout(config *config.ApplicationConfig) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := config.CookieStore.Get(r, config.SessionName)
|
||||
session.Options.MaxAge = -1
|
||||
|
||||
err := session.Save(r, w)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to delete session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
36
internal/api/middleware/oidc.go
Normal file
36
internal/api/middleware/oidc.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
)
|
||||
|
||||
func OidcMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) {
|
||||
middleware := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := config.CookieStore.Get(r, config.SessionName)
|
||||
rawIDToken, ok := session.Values["id_token"].(string)
|
||||
if !ok {
|
||||
// Not authenticated; redirect to login
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, _, err := verifyTokenAndGetClaims(config, r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
session.Options.MaxAge = -1
|
||||
session.Save(r, w)
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Add token to context
|
||||
ctx := context.WithValue(r.Context(), "id_token", idToken)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
return middleware, nil
|
||||
}
|
||||
48
internal/api/middleware/pagination.go
Normal file
48
internal/api/middleware/pagination.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Pagination struct {
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
const PaginationContextKey = "pagination"
|
||||
|
||||
func PaginationMiddleware() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
page := 1
|
||||
pageSize := 20
|
||||
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
if parsed, err := strconv.Atoi(p); err == nil && parsed >= 1 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if ps := r.URL.Query().Get("page_size"); ps != "" {
|
||||
if parsed, err := strconv.Atoi(ps); err == nil && parsed >= 1 {
|
||||
pageSize = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), PaginationContextKey, Pagination{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
36
internal/api/requests/module.go
Normal file
36
internal/api/requests/module.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package views_api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/api/middleware"
|
||||
"clintonambulance.com/calculate_negative_points/internal/api/requests/negative_points_processor"
|
||||
"clintonambulance.com/calculate_negative_points/internal/api/requests/users"
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/swaggest/rest/nethttp"
|
||||
"github.com/swaggest/rest/web"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func MountInternalApiEndpoints(e *web.Service, config *config.ApplicationConfig, logger *zap.Logger) {
|
||||
e.Route("/api", func(r chi.Router) {
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:3000"},
|
||||
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
currentUserMiddlware, err := middleware.CurrentUserMiddleware(config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
r.Use(currentUserMiddlware)
|
||||
|
||||
r.Method(http.MethodGet, "/users/current", nethttp.NewHandler(users.GetCurrentUser()))
|
||||
r.Method(http.MethodPost, "/process", nethttp.NewHandler(negative_points_processor.NegativePointsProcessor(config, logger)))
|
||||
r.With(middleware.Logout(config)).Method(http.MethodDelete, "/users/current", nethttp.NewHandler(users.Logout()))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package negative_points_processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/api/middleware"
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
"clintonambulance.com/calculate_negative_points/internal/nocodb"
|
||||
"clintonambulance.com/calculate_negative_points/internal/utils"
|
||||
"github.com/samber/lo"
|
||||
"github.com/swaggest/usecase"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type negativePointsProcessorInput struct {
|
||||
File *multipart.FileHeader `formData:"file" description:"XLS schedule file to process"`
|
||||
}
|
||||
|
||||
type negativePointsProcessorOutput struct {
|
||||
Employees []string `json:"employees" description:"List of employees who had negative points" nullable:"false" required:"true"`
|
||||
}
|
||||
|
||||
func hasMatch(record nocodb.NocoDBRecord, candidates []string, threshold int) bool {
|
||||
normalizedName := utils.NormalizeName(utils.ToTitleCase(record.Name))
|
||||
bestDistance := threshold + 1
|
||||
|
||||
for _, candidate := range candidates {
|
||||
normalizedCandidate := utils.NormalizeName(candidate)
|
||||
distance := utils.LevenshteinDistance(normalizedName, normalizedCandidate)
|
||||
|
||||
if distance < bestDistance {
|
||||
bestDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
if bestDistance <= threshold {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func NegativePointsProcessor(config *config.ApplicationConfig, logger *zap.Logger) usecase.Interactor {
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input negativePointsProcessorInput, output *negativePointsProcessorOutput) error {
|
||||
ctxUser := ctx.Value("claims").(middleware.Claims)
|
||||
|
||||
file, err := input.File.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
employees, err := utils.ParseUploadedXLSFile(input.File)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records, err := nocodb.Fetch(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
employees = lo.Filter(employees, func(e utils.Employee, _ int) bool { return e.Worked() })
|
||||
|
||||
names := lo.Map(employees, func(e utils.Employee, _ int) string { return e.Name })
|
||||
|
||||
// Convert XLS names to same format as API names (FirstName LastName) and normalize casing
|
||||
xlsNamesConverted := lo.Map(names, func(name string, _ int) string {
|
||||
return utils.ToTitleCase(utils.ConvertNameFormat(name))
|
||||
})
|
||||
|
||||
overlaps := lo.Filter(records, func(r nocodb.NocoDBRecord, _ int) bool {
|
||||
return hasMatch(r, xlsNamesConverted, config.MatchThreshold)
|
||||
})
|
||||
|
||||
currentNocodbUser, found := lo.Find(records, func(r nocodb.NocoDBRecord) bool { return hasMatch(r, []string{ctxUser.Name}, config.MatchThreshold) })
|
||||
|
||||
if !found {
|
||||
return errors.New("Unable to match API user to NocoDB user")
|
||||
}
|
||||
|
||||
requestObjects := lo.Map(overlaps, func(r nocodb.NocoDBRecord, _ int) nocodb.NocoDBRequest {
|
||||
return nocodb.NocoDBRequest{
|
||||
EmployeeId: r.ID,
|
||||
ReportedBy: currentNocodbUser.ID,
|
||||
InfractionId: config.NocoDBConfig.NegativeInfractionId,
|
||||
Date: utils.FirstDayOfMonth(),
|
||||
}
|
||||
})
|
||||
|
||||
// Marshal request objects to JSON
|
||||
jsonData, err := json.Marshal(requestObjects)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create POST request
|
||||
req, err := http.NewRequest("POST", nocodb.AddInfractionsUrl(config), strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add authorization header
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.NocoDBConfig.ApiToken.Value()))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Make the request
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Request failed with status %d: %s\n", resp.StatusCode, string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
output.Employees = lo.Map(overlaps, func(r nocodb.NocoDBRecord, _ int) string { return r.Name })
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
u.SetDescription("Process Negative Points")
|
||||
u.SetTags("Negative Points Processor")
|
||||
|
||||
return u
|
||||
}
|
||||
31
internal/api/requests/users/current.go
Normal file
31
internal/api/requests/users/current.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go/types"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/api/middleware"
|
||||
internal_types "clintonambulance.com/calculate_negative_points/internal/types"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/samber/lo"
|
||||
"github.com/swaggest/usecase"
|
||||
)
|
||||
|
||||
func GetCurrentUser() usecase.Interactor {
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input types.Nil, output *internal_types.UiUserResponse) error {
|
||||
ctxId := ctx.Value("user").(*oidc.IDToken)
|
||||
ctxUser := ctx.Value("claims").(middleware.Claims)
|
||||
|
||||
if lo.IsNotEmpty(ctxId.Issuer) {
|
||||
output.Item = internal_types.UiUser{
|
||||
Name: ctxUser.Name,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
u.SetDescription("Retrieve the current user")
|
||||
u.SetTags("Users")
|
||||
return u
|
||||
}
|
||||
17
internal/api/requests/users/logout.go
Normal file
17
internal/api/requests/users/logout.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go/types"
|
||||
|
||||
"github.com/swaggest/usecase"
|
||||
)
|
||||
|
||||
func Logout() usecase.Interactor {
|
||||
u := usecase.NewInteractor(func(ctx context.Context, input types.Nil, output *map[string]interface{}) error {
|
||||
return nil
|
||||
})
|
||||
u.SetDescription("Logout current user")
|
||||
u.SetTags("Users")
|
||||
return u
|
||||
}
|
||||
335
internal/config/config.go
Normal file
335
internal/config/config.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/dsa0x/sicher"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/knadh/koanf/parsers/yaml"
|
||||
"github.com/knadh/koanf/providers/confmap"
|
||||
"github.com/knadh/koanf/providers/env"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/providers/posflag"
|
||||
"github.com/knadh/koanf/providers/structs"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/pflag"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const defaultEnvironment = "development"
|
||||
|
||||
type SicherObject interface {
|
||||
SetEnvStyle(string)
|
||||
LoadEnv(string, interface{}) error
|
||||
}
|
||||
|
||||
type SicherConfig struct {
|
||||
AppSecret string `env:"APP_SECRET" koanf:"app_secret"`
|
||||
AppSecretBlock string `env:"APP_SECRET_BLOCK" koanf:"app_secret_block"`
|
||||
NocoDBToken string `env:"NOCODB_TOKEN" koanf:"nocodb.api_token"`
|
||||
OidcClientId string `env:"OIDC_CLIENT_ID" koanf:"oidc.client_id"`
|
||||
OidcClientSecret string `env:"OIDC_CLIENT_SECRET" koanf:"oidc.client_secret"`
|
||||
ServiceAccountId string `env:"IDMS_SERVICE_ACCOUNT_ID" koanf:"idms.service_account_id"`
|
||||
ServiceAccountPassword string `env:"IDMS_SERVICE_ACCOUNT_PASSWORD" koanf:"idms.service_account_password"`
|
||||
}
|
||||
|
||||
var NewSicherObject = func(environment string, path string) SicherObject {
|
||||
return sicher.New(environment, path)
|
||||
}
|
||||
|
||||
type OidcConfig struct {
|
||||
ClientId string `koanf:"oidc.client_id" validate:"required"`
|
||||
ClientSecret Secret[string] `koanf:"oidc.client_secret" validate:"required"`
|
||||
Issuer string `koanf:"oidc.issuer" validate:"required"`
|
||||
Scopes []string `koanf:"oidc.scopes" validate:"required"`
|
||||
Provider *oidc.Provider
|
||||
Verifier *oidc.IDTokenVerifier
|
||||
OAuth2 *oauth2.Config
|
||||
}
|
||||
|
||||
type IdmsConfig struct {
|
||||
BaseUrl string `koanf:"idms.base_url" validate:"required"`
|
||||
CarsGroupId string `koanf:"idms.cars_group_id" validate:"required"`
|
||||
Id Secret[string] `koanf:"idms.service_account_id" validate:"required"`
|
||||
Password Secret[string] `koanf:"idms.service_account_password" validate:"required"`
|
||||
}
|
||||
|
||||
type NocoDBConfig struct {
|
||||
ApiToken Secret[string] `koanf:"nocodb.api_token" validate:"required"`
|
||||
BaseUrl string `koanf:"nocodb.base_url" validate:"required"`
|
||||
EmployeesTableId string `koanf:"nocodb.employees_table_id" validate:"required"`
|
||||
InfractionsTableId string `koanf:"nocodb.infractions_table_id" validate:"required"`
|
||||
NegativeInfractionId int `koanf:"nocodb.negative_infraction_id" validate:"required"`
|
||||
NoPointsViewId string `koanf:"nocodb.no_points_view_id" validate:"required"`
|
||||
}
|
||||
|
||||
type ApplicationConfig struct {
|
||||
AppSecret Secret[string] `koanf:"app_secret"`
|
||||
AppSecretBlock Secret[string] `koanf:"app_secret_block"`
|
||||
CookieStore *sessions.CookieStore
|
||||
Environment string `koanf:"environment" validate:"required"`
|
||||
Listen string `koanf:"listen" validate:"required,hostname_port"`
|
||||
MatchThreshold int `koanf:"match_threshold" validate:"required"`
|
||||
NocoDBConfig NocoDBConfig `koanf:"nocodb" validate:"required"`
|
||||
OidcConfig OidcConfig `koanf:"oidc" validate:"required"`
|
||||
PublicPath string `koanf:"paths.public"`
|
||||
Idms IdmsConfig `koanf:"idms" validate:"required"`
|
||||
SessionName string
|
||||
ViewPath string `koanf:"paths.views"`
|
||||
}
|
||||
|
||||
type ParsedCliArgs struct {
|
||||
ConfigFiles *[]string
|
||||
DatabasePasswordPath *string
|
||||
Environment *string
|
||||
Listen *string
|
||||
Version *bool
|
||||
}
|
||||
|
||||
func ParseArgs(version Version, exit func(int), args []string) (*ParsedCliArgs, *pflag.FlagSet) {
|
||||
f := pflag.NewFlagSet("config", pflag.ContinueOnError)
|
||||
f.Usage = func() {
|
||||
fmt.Println(version.AppNameAndVersion(false))
|
||||
fmt.Println(f.FlagUsages())
|
||||
exit(0)
|
||||
}
|
||||
|
||||
parsedCliArgs := &ParsedCliArgs{
|
||||
ConfigFiles: f.StringSliceP("conf", "c", []string{}, "path to one or more .yaml config files"),
|
||||
DatabasePasswordPath: f.StringP("db-secret-path", "d", "", "path to database secret"),
|
||||
Environment: f.StringP("environment", "e", defaultEnvironment, "Environment for the running binary"),
|
||||
Listen: f.StringP("listen", "l", "0.0.0.0:3000", "address and port to listen on"),
|
||||
Version: f.Bool("version", false, "Show version"),
|
||||
}
|
||||
|
||||
err := f.Parse(args[1:])
|
||||
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(
|
||||
os.Stderr,
|
||||
"%s\n\nERROR: %s\n\n%s",
|
||||
version.AppNameAndVersion(false), err, f.FlagUsages(),
|
||||
)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
return parsedCliArgs, f
|
||||
}
|
||||
|
||||
// Load application configuration from config files, command line flags, and environment variables.
|
||||
// An error will be returned only if _unexpected_ error happened, such as a file not being found.
|
||||
// If the user specified a --help or a --version flag, the application will exit with a 0 status code.
|
||||
// If the user specified an unsupported flag, the application will exit with a 1 status code and print the help message.
|
||||
func Load(version Version, exit func(int), args []string, logger *zap.Logger) (*ApplicationConfig, error) {
|
||||
k := koanf.New(".")
|
||||
config := defaultConfig()
|
||||
|
||||
err := k.Load(confmap.Provider(config, "."), nil)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal("error initializing config", zap.Error(err))
|
||||
}
|
||||
|
||||
parsedCliArgs, f := ParseArgs(version, exit, args)
|
||||
|
||||
// Load the config files provided in the command line.
|
||||
for _, c := range *parsedCliArgs.ConfigFiles {
|
||||
err := k.Load(file.Provider(c), yaml.Parser())
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading configuration file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = godotenv.Load()
|
||||
if err != nil {
|
||||
logger.Info("No .env file found")
|
||||
}
|
||||
|
||||
configPath := func(p string) string {
|
||||
combinedPath := fmt.Sprintf("%s/%s", k.String("path_prefix"), p)
|
||||
return combinedPath
|
||||
}
|
||||
|
||||
// Override with environment variables
|
||||
lo.ForEach([]string{envPrefix, pgPrefix, databasePrefix, idmsPrefix, oidcPrefix}, func(prefix string, _ int) {
|
||||
if err := k.Load(env.Provider(prefix, "_", mapEnvVarNames(prefix)), nil); err != nil {
|
||||
logger.Fatal("error parsing environment variables", zap.Error(err))
|
||||
}
|
||||
})
|
||||
|
||||
// Override with command-line values
|
||||
if err := k.Load(posflag.Provider(f, ".", k), nil); err != nil {
|
||||
return nil, fmt.Errorf("error parsing command-line parameters: %v", err)
|
||||
}
|
||||
|
||||
if err := k.Unmarshal("", &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if *parsedCliArgs.Version {
|
||||
fmt.Println(version.AppNameAndVersion(true))
|
||||
exit(0)
|
||||
}
|
||||
|
||||
var sicherConfig SicherConfig
|
||||
|
||||
s := NewSicherObject(k.String("environment"), configPath(k.String("paths.credentials")))
|
||||
s.SetEnvStyle("yaml") // default is dotenv
|
||||
err = s.LoadEnv("", &sicherConfig)
|
||||
|
||||
cookieStore := sessions.NewCookieStore([]byte(k.String("app_secret")))
|
||||
cookieStore.Options.HttpOnly = true
|
||||
cookieStore.Options.Secure = *parsedCliArgs.Environment != "dev" && *parsedCliArgs.Environment != "testEnvironment" // Use HTTPS
|
||||
cookieStore.Options.SameSite = http.SameSiteLaxMode
|
||||
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
if err = k.Load(structs.Provider(sicherConfig, "koanf"), nil); err != nil {
|
||||
return nil, fmt.Errorf("error parsing sicher parameters: %v", err)
|
||||
}
|
||||
|
||||
var appConfig ApplicationConfig
|
||||
|
||||
unmarshalConf := koanf.UnmarshalConf{
|
||||
DecoderConfig: &mapstructure.DecoderConfig{
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||
SecretFilePathUnmarshalHookFunc(),
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
mapstructure.TextUnmarshallerHookFunc(),
|
||||
),
|
||||
Metadata: nil,
|
||||
Result: &appConfig,
|
||||
WeaklyTypedInput: true,
|
||||
},
|
||||
}
|
||||
|
||||
if err := k.UnmarshalWithConf("", &config, unmarshalConf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
appConfig.AppSecret = SecretFromValue(k.String("app_secret"))
|
||||
appConfig.AppSecretBlock = SecretFromValue(k.String("app_secret_block"))
|
||||
|
||||
nocodbConfig := NocoDBConfig{
|
||||
ApiToken: SecretFromValue(k.String("nocodb.api_token")),
|
||||
BaseUrl: k.String("nocodb.base_url"),
|
||||
EmployeesTableId: k.String("nocodb.employees_table_id"),
|
||||
InfractionsTableId: k.String("nocodb.infractions_table_id"),
|
||||
NegativeInfractionId: k.Int("nocodb.negative_infraction_id"),
|
||||
NoPointsViewId: k.String("nocodb.no_points_view_id"),
|
||||
}
|
||||
|
||||
oidcConfig := OidcConfig{
|
||||
ClientId: k.String("oidc.client_id"),
|
||||
ClientSecret: SecretFromValue(k.String("oidc.client_secret")),
|
||||
Issuer: k.String("oidc.issuer"),
|
||||
Scopes: k.Strings("oidc.scopes"),
|
||||
}
|
||||
|
||||
if *parsedCliArgs.Environment != "testEnvironment" {
|
||||
authProvider, err := oidc.NewProvider(context.Background(), k.String("oidc.issuer"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oidcConfig.Provider = authProvider
|
||||
oidcConfig.Verifier = authProvider.Verifier(&oidc.Config{ClientID: k.String("oidc.client_id")})
|
||||
redirectUrl := fmt.Sprintf("%s/auth/callback", k.String("oidc.redirect_url"))
|
||||
oidcConfig.OAuth2 = &oauth2.Config{
|
||||
ClientID: k.String("oidc.client_id"),
|
||||
ClientSecret: k.String("oidc.client_secret"),
|
||||
Endpoint: authProvider.Endpoint(),
|
||||
RedirectURL: redirectUrl,
|
||||
Scopes: k.Strings("oidc.scopes"),
|
||||
}
|
||||
}
|
||||
|
||||
appConfig.CookieStore = cookieStore
|
||||
appConfig.Environment = *parsedCliArgs.Environment
|
||||
appConfig.Idms = IdmsConfig{
|
||||
BaseUrl: k.String("idms.base_url"),
|
||||
CarsGroupId: k.String("idms.cars_group_id"),
|
||||
Id: SecretFromValue(k.String("idms.service_account_id")),
|
||||
Password: SecretFromValue(k.String("idms.service_account_password")),
|
||||
}
|
||||
appConfig.Listen = *parsedCliArgs.Listen
|
||||
appConfig.MatchThreshold = k.Int("match_threshold")
|
||||
appConfig.NocoDBConfig = nocodbConfig
|
||||
appConfig.OidcConfig = oidcConfig
|
||||
appConfig.PublicPath = configPath(k.String("paths.public"))
|
||||
appConfig.SessionName = "calculate-negative-points"
|
||||
appConfig.ViewPath = configPath(k.String("paths.views"))
|
||||
|
||||
validate := validator.New()
|
||||
validate.RegisterCustomTypeFunc(secretTypeTranslator[string], Secret[string]{})
|
||||
err = validate.Struct(appConfig)
|
||||
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
fmt.Print("Could not create config")
|
||||
exit(1)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &appConfig, nil
|
||||
}
|
||||
|
||||
const envPrefix = "APP_"
|
||||
const pgPrefix = "PG_"
|
||||
const databasePrefix = "DATABASE_"
|
||||
const idmsPrefix = "IDMS_"
|
||||
const oidcPrefix = "OIDC_"
|
||||
|
||||
/*
|
||||
* Rio passes in PG_HOST and PG_PORT so we need to replace the PG string with DATABASE and then not trim the prefix
|
||||
*/
|
||||
func mapEnvVarNames(prefix string) func(s string) string {
|
||||
return func(s string) string {
|
||||
if prefix == databasePrefix {
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
|
||||
replacedPg := strings.Replace(s, "PG", "DATABASE", -1)
|
||||
|
||||
lower := strings.ToLower(strings.TrimPrefix(replacedPg, prefix))
|
||||
|
||||
return lower
|
||||
}
|
||||
}
|
||||
|
||||
func defaultConfig() map[string]interface{} {
|
||||
dbConfig := map[string]interface{}{
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"user": "postgres",
|
||||
"name": "calculate-negative-points",
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"database": dbConfig,
|
||||
"environment": defaultEnvironment,
|
||||
"listen": "127.0.0.1:3000",
|
||||
}
|
||||
}
|
||||
|
||||
func secretTypeTranslator[T SecretValue](field reflect.Value) interface{} {
|
||||
if secret, ok := field.Interface().(Secret[T]); ok {
|
||||
return secret.Value()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
13
internal/config/config_suite_test.go
Normal file
13
internal/config/config_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Config Suite")
|
||||
}
|
||||
41
internal/config/config_test.go
Normal file
41
internal/config/config_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type FakeSicherObject struct{}
|
||||
|
||||
func (_ FakeSicherObject) SetEnvStyle(_ string) {}
|
||||
func (_ FakeSicherObject) LoadEnv(_ string, obj interface{}) error {
|
||||
var err error
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var _ = Describe("ConfigTest", func() {
|
||||
var version config.Version
|
||||
var logger, _ = config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment"})
|
||||
shouldNotExit := func(code int) {
|
||||
// "Fatal" to mimic os.Exit
|
||||
log.Default().Print("exit called with code", code)
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
config.NewSicherObject = func(_ string, _ string) config.SicherObject {
|
||||
return FakeSicherObject{}
|
||||
}
|
||||
version = config.Version{Release: "1.0.0", Commit: "abcdef", Date: "2023-01-01"}
|
||||
})
|
||||
|
||||
It("Errors if the config file is not found", func() {
|
||||
argv := []string{"cmd", "-c", "config/no-such-file.yml"}
|
||||
_, err := config.Load(version, shouldNotExit, argv, logger)
|
||||
Expect(err.Error()).To(Equal("error loading configuration file: open config/no-such-file.yml: no such file or directory"))
|
||||
})
|
||||
})
|
||||
34
internal/config/logging.go
Normal file
34
internal/config/logging.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func NewLogger(version Version, output io.Writer, args []string) (*zap.Logger, func()) {
|
||||
parsedCliArgs, _ := ParseArgs(version, os.Exit, args)
|
||||
syncs := []zapcore.WriteSyncer{zapcore.AddSync(output)}
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.EncodeTime = zapcore.RFC3339NanoTimeEncoder
|
||||
|
||||
core := zapcore.NewCore(
|
||||
zapcore.NewJSONEncoder(encoderConfig),
|
||||
zap.CombineWriteSyncers(syncs...),
|
||||
zap.DebugLevel,
|
||||
)
|
||||
logger := zap.New(core).WithOptions(
|
||||
zap.AddStacktrace(zap.ErrorLevel),
|
||||
zap.WithCaller(false),
|
||||
).With(
|
||||
zap.String("version", version.Release),
|
||||
zap.String("environment", *parsedCliArgs.Environment),
|
||||
)
|
||||
finalizer := func() {
|
||||
// This might fail if logging to console, but we don't care (https://github.com/uber-go/zap/issues/880)
|
||||
_ = logger.Sync()
|
||||
}
|
||||
return logger, finalizer
|
||||
}
|
||||
85
internal/config/secret.go
Normal file
85
internal/config/secret.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
)
|
||||
|
||||
// SecretValue defines what types of secrets this secret container supports
|
||||
type SecretValue interface {
|
||||
~string | ~[]string | ~map[string]string
|
||||
}
|
||||
|
||||
// SecretFilePath is the source to the file containing the secret.
|
||||
type SecretFilePath string
|
||||
|
||||
const SecretPlaceholder = "[SECRET]"
|
||||
|
||||
// Secret encapsulates a secret value, which is loaded from a file, or directly from configuration / environment variable
|
||||
type Secret[T SecretValue] struct {
|
||||
value T
|
||||
source SecretFilePath
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface, used to hide the actual secret value in logs etc.
|
||||
func (s Secret[T]) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(map[string]string{"value": SecretPlaceholder, "source": string(s.source)})
|
||||
}
|
||||
|
||||
// MarshalText implements the encoding.TextMarshaler interface, used to hide the actual secret value in logs etc.
|
||||
func (s *Secret[T]) MarshalText() ([]byte, error) {
|
||||
return []byte(SecretPlaceholder), nil
|
||||
}
|
||||
|
||||
// UnmarshalText implements the encoding.TextUnmarshaler interface, used to load the secret from config files and env. values
|
||||
func (s *Secret[T]) UnmarshalText(text []byte) error {
|
||||
var val T
|
||||
switch any(val).(type) {
|
||||
case string:
|
||||
s.value = any(string(text)).(T)
|
||||
return nil
|
||||
default:
|
||||
return json.Unmarshal(text, &s.value)
|
||||
}
|
||||
}
|
||||
|
||||
func (s Secret[T]) Value() T {
|
||||
return s.value
|
||||
}
|
||||
|
||||
func SecretFromSecretPath[T SecretValue](path SecretFilePath) (Secret[T], error) {
|
||||
contentBytes, err := os.ReadFile(string(path))
|
||||
if err != nil {
|
||||
return Secret[T]{}, err
|
||||
}
|
||||
secret := Secret[T]{source: path}
|
||||
err = secret.UnmarshalText(bytes.TrimSpace(contentBytes))
|
||||
return secret, err
|
||||
}
|
||||
|
||||
func SecretFromValue[T SecretValue](value T) Secret[T] {
|
||||
return Secret[T]{value: value}
|
||||
}
|
||||
|
||||
// SecretFilePathUnmarshalHookFunc is a mapstructure.DecodeHookFunc that will convert a SecretFilePath to a Secret
|
||||
func SecretFilePathUnmarshalHookFunc() mapstructure.DecodeHookFuncType {
|
||||
return func(from, to reflect.Type, data interface{}) (interface{}, error) {
|
||||
if from != reflect.TypeOf(SecretFilePath("")) {
|
||||
return data, nil
|
||||
}
|
||||
// Reflection does not work with generics as of 1.20, so we have to do this manually
|
||||
if to == reflect.TypeOf(Secret[map[string]string]{}) {
|
||||
return SecretFromSecretPath[map[string]string](data.(SecretFilePath))
|
||||
} else if to == reflect.TypeOf(Secret[[]string]{}) {
|
||||
return SecretFromSecretPath[[]string](data.(SecretFilePath))
|
||||
} else if to == reflect.TypeOf(Secret[string]{}) {
|
||||
return SecretFromSecretPath[string](data.(SecretFilePath))
|
||||
} else {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
1
internal/config/testdata/DATABASE_SECRET.sample
vendored
Normal file
1
internal/config/testdata/DATABASE_SECRET.sample
vendored
Normal file
@@ -0,0 +1 @@
|
||||
fakesecret
|
||||
22
internal/config/testdata/settings.test.yml
vendored
Normal file
22
internal/config/testdata/settings.test.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
app_secret: "app secret"
|
||||
app_secret_block: "app secret block hash"
|
||||
path_prefix: "."
|
||||
paths:
|
||||
migrations: "db/migrations"
|
||||
credentials: "config/credentials"
|
||||
match_threshold: 3
|
||||
nocodb:
|
||||
api_token: test
|
||||
base_url: https://example.com
|
||||
employees_table_id: "1234567890"
|
||||
infractions_table_id: "2468013579"
|
||||
negative_infraction_id: 1
|
||||
no_points_view_id: "1357924680"
|
||||
oidc:
|
||||
issuer: http://example.com
|
||||
redirect_url: http://example.com
|
||||
idms:
|
||||
base_url: https://example.com
|
||||
cars_group_id: "1234"
|
||||
id: "1234"
|
||||
password: "5678"
|
||||
34
internal/config/version.go
Normal file
34
internal/config/version.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package config
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Version struct {
|
||||
Release string
|
||||
Commit string
|
||||
Date string
|
||||
}
|
||||
|
||||
var (
|
||||
release = "dev"
|
||||
commit = ""
|
||||
date = ""
|
||||
)
|
||||
|
||||
func (v *Version) AppNameAndVersion(details bool) string {
|
||||
result := fmt.Sprintf("Basin Feature Flag Service %s", v.Release)
|
||||
if details && v.Commit != "" {
|
||||
result = fmt.Sprintf("%s\nGit commit: %s", result, v.Commit)
|
||||
}
|
||||
if details && v.Date != "" {
|
||||
result = fmt.Sprintf("%s\nBuilt at: %s", result, v.Date)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func NewVersion() Version {
|
||||
return Version{
|
||||
Release: release,
|
||||
Commit: commit,
|
||||
Date: date,
|
||||
}
|
||||
}
|
||||
103
internal/idms/request.go
Normal file
103
internal/idms/request.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package idms
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
type Method string
|
||||
|
||||
const Get Method = "GET"
|
||||
const Post Method = "POST"
|
||||
const Put Method = "PUT"
|
||||
const Delete Method = "Delete"
|
||||
|
||||
type ResponsePagination struct {
|
||||
Count int `json:"count"`
|
||||
Current int `json:"current"`
|
||||
EndIndex int `json:"end_index"`
|
||||
Next int `json:"next"`
|
||||
Previous int `json:"previous"`
|
||||
StartIndex int `json:"start_index"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type IdmsResponse[T any] struct {
|
||||
Pagination ResponsePagination `json:"pagination"`
|
||||
Results []T `json:"results"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
type IdmsRequest[T any] struct {
|
||||
Method Method
|
||||
Path string
|
||||
ResponseDecoder *IdmsResponse[T]
|
||||
}
|
||||
|
||||
func newRestyRequest(url string, path string) *resty.Request {
|
||||
client := resty.New()
|
||||
formattedUrl := fmt.Sprintf("%s%s", url, path)
|
||||
req := client.R()
|
||||
req.SetURL(formattedUrl)
|
||||
defer client.Close()
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func GetToken(config *config.ApplicationConfig) (string, error) {
|
||||
req := newRestyRequest(config.Idms.BaseUrl, "/application/o/token/")
|
||||
|
||||
req.SetResult(&TokenResponse{})
|
||||
req.SetMethod(string(Post))
|
||||
req.SetFormData(map[string]string{
|
||||
"client_id": config.Idms.Id.Value(),
|
||||
"grant_type": "client_credentials",
|
||||
"password": config.Idms.Password.Value(),
|
||||
"scope": "goauthentik.io/api",
|
||||
"username": "service",
|
||||
})
|
||||
|
||||
res, err := req.Send()
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data := res.Result().(*TokenResponse).AccessToken
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func NewRequest[T any](path string, method Method) IdmsRequest[T] {
|
||||
return IdmsRequest[T]{
|
||||
Method: method,
|
||||
Path: path,
|
||||
ResponseDecoder: &IdmsResponse[T]{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r IdmsRequest[T]) Perform(config *config.ApplicationConfig, bodyData interface{}, headers map[string]string) ([]T, error) {
|
||||
req := newRestyRequest(config.Idms.BaseUrl, r.Path)
|
||||
token, err := GetToken(config)
|
||||
|
||||
if err != nil {
|
||||
return []T{}, err
|
||||
}
|
||||
|
||||
req.SetResult(r.ResponseDecoder).SetAuthToken(token).SetContentType("application/json")
|
||||
req.SetMethod(string(r.Method))
|
||||
res, err := req.Send()
|
||||
|
||||
if err != nil {
|
||||
return []T{}, err
|
||||
}
|
||||
|
||||
data := res.Result().(*IdmsResponse[T]).Results
|
||||
|
||||
return data, nil
|
||||
}
|
||||
74
internal/idms/users.go
Normal file
74
internal/idms/users.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package idms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// FlexibleString handles JSON values that can be either strings or numbers
|
||||
type FlexibleString string
|
||||
|
||||
func (f *FlexibleString) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
*f = FlexibleString(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(data, &n); err == nil {
|
||||
*f = FlexibleString(n.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot unmarshal %s into FlexibleString", data)
|
||||
}
|
||||
|
||||
type IdmsUserAttributes struct {
|
||||
Cars bool `json:"cars"`
|
||||
EmployeeId FlexibleString `json:"employee_id"`
|
||||
}
|
||||
|
||||
type IdmsUser struct {
|
||||
Attributes IdmsUserAttributes `json:"attributes"`
|
||||
Groups []string `json:"groups"`
|
||||
Id string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (u IdmsUser) FirstName() string {
|
||||
parts := strings.Fields(u.Name)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func (u IdmsUser) LastName() string {
|
||||
parts := strings.Fields(u.Name)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
func GetCarsMembers(config *config.ApplicationConfig) []IdmsUser {
|
||||
req := NewRequest[IdmsUser]("/api/v3/core/users/?is_active=true", Get)
|
||||
results, _ := req.Perform(config, map[string]string{}, map[string]string{})
|
||||
|
||||
filtered := lo.Filter(results, func(u IdmsUser, _ int) bool {
|
||||
return lo.Contains(u.Groups, config.Idms.CarsGroupId)
|
||||
})
|
||||
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].LastName() < filtered[j].LastName()
|
||||
})
|
||||
|
||||
return filtered
|
||||
}
|
||||
13
internal/models/models_suite_test.go
Normal file
13
internal/models/models_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestModels(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Models Suite")
|
||||
}
|
||||
108
internal/nocodb/request.go
Normal file
108
internal/nocodb/request.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package nocodb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
)
|
||||
|
||||
type NocoDBRecord struct {
|
||||
ID int `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
// NocoDBResponse represents the API response structure
|
||||
type NocoDBResponse struct {
|
||||
List []NocoDBRecord `json:"list"`
|
||||
PageInfo struct {
|
||||
TotalRows int `json:"totalRows"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
IsFirstPage bool `json:"isFirstPage"`
|
||||
IsLastPage bool `json:"isLastPage"`
|
||||
} `json:"pageInfo"`
|
||||
}
|
||||
|
||||
type NocoDBRequest struct {
|
||||
EmployeeId int `json:"Employees_id"`
|
||||
ReportedBy int `json:"Employees_id1"`
|
||||
InfractionId int `json:"Infractions_id"`
|
||||
Date string `json:"Date"`
|
||||
}
|
||||
|
||||
func NoPointsUrl(config *config.ApplicationConfig) string {
|
||||
combinedUrl, _ := url.JoinPath(config.NocoDBConfig.BaseUrl, "api/v2/tables", config.NocoDBConfig.EmployeesTableId, "records")
|
||||
return fmt.Sprintf("%s?viewId=%s", combinedUrl, config.NocoDBConfig.NoPointsViewId)
|
||||
}
|
||||
|
||||
func AddInfractionsUrl(config *config.ApplicationConfig) string {
|
||||
combinedUrl, _ := url.JoinPath(config.NocoDBConfig.BaseUrl, "api/v2/tables", config.NocoDBConfig.InfractionsTableId, "records")
|
||||
return combinedUrl
|
||||
}
|
||||
|
||||
func Fetch(config *config.ApplicationConfig) ([]NocoDBRecord, error) {
|
||||
records := []NocoDBRecord{}
|
||||
offset := 0
|
||||
limit := 25
|
||||
isLastPage := false
|
||||
|
||||
baseURL := NoPointsUrl(config)
|
||||
|
||||
for !isLastPage {
|
||||
// Create HTTP request with base URL
|
||||
req, err := http.NewRequest("GET", baseURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set query params via the request's URL
|
||||
q := req.URL.Query()
|
||||
q.Set("offset", fmt.Sprintf("%d", offset))
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
// Add authorization header
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.NocoDBConfig.ApiToken.Value()))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Make the request
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch data from API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Read and parse response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var nocoDBResp NocoDBResponse
|
||||
if err := json.Unmarshal(body, &nocoDBResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
||||
}
|
||||
|
||||
// Extract names from records
|
||||
records = append(records, nocoDBResp.List...)
|
||||
|
||||
// Check if we've reached the last page
|
||||
isLastPage = nocoDBResp.PageInfo.IsLastPage
|
||||
|
||||
// Update offset for next page
|
||||
offset += limit
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
64
internal/server/http.go
Normal file
64
internal/server/http.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
apimiddleware "clintonambulance.com/calculate_negative_points/internal/api/middleware"
|
||||
views_api "clintonambulance.com/calculate_negative_points/internal/api/requests"
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
internal_web "clintonambulance.com/calculate_negative_points/internal/web"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/swaggest/openapi-go/openapi31"
|
||||
"github.com/swaggest/rest/response"
|
||||
"github.com/swaggest/rest/web"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func errorResponder(w http.ResponseWriter, error string, code int) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(ErrorResponse{Message: error})
|
||||
}
|
||||
|
||||
func notFoundResponder(w http.ResponseWriter, _r *http.Request) {
|
||||
errorResponder(w, "Not Found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func NewHttpServer(logger *zap.Logger, version config.Version) (*web.Service, *openapi31.Reflector) {
|
||||
reflector := openapi31.NewReflector()
|
||||
service := web.NewService(reflector)
|
||||
|
||||
service.OpenAPISchema().SetTitle("Calculate Negative Points")
|
||||
service.OpenAPISchema().SetDescription("Easily maintain Calculate Negative Points")
|
||||
service.OpenAPISchema().SetVersion("v1.0.0")
|
||||
|
||||
service.Use(
|
||||
middleware.RealIP,
|
||||
middleware.RequestID,
|
||||
apimiddleware.AddRequestIDHeaderMiddleware,
|
||||
apimiddleware.LoggingMiddleware(logger),
|
||||
apimiddleware.ErrorHandler,
|
||||
)
|
||||
service.NotFound(notFoundResponder)
|
||||
|
||||
return service, reflector
|
||||
}
|
||||
|
||||
func MountAllEndpoints(srv *web.Service, version config.Version, config *config.ApplicationConfig, logger *zap.Logger) {
|
||||
views_api.MountInternalApiEndpoints(srv, config, logger)
|
||||
|
||||
// This should be last because it does wildcard matching
|
||||
internal_web.MountWebEndpoints(srv, config, logger)
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Override some global defaults:
|
||||
// This is just "application/json" by default, let's include the charset as well.
|
||||
response.DefaultSuccessResponseContentType = "application/json; charset=UTF-8"
|
||||
}
|
||||
51
internal/server/http_test.go
Normal file
51
internal/server/http_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"testing"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
"clintonambulance.com/calculate_negative_points/internal/server"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestServer(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Http Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("NewHttpServer", func() {
|
||||
It("Creates a new HttpServer", func() {
|
||||
var logOutput bytes.Buffer
|
||||
version := config.Version{Release: "test-version"}
|
||||
logger, _ := config.NewLogger(version, &logOutput, []string{"cmd", "-e", "testEnvironment"})
|
||||
srv, _ := server.NewHttpServer(logger, version)
|
||||
srv.Method(http.MethodHead, "/dummy-route", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foobar", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
// Validate that the server responds with the web frontend for unknown routes
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(rec.Header().Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
||||
Expect(rec.Body.String()).To(ContainSubstring(`{"message":"Not Found"}`))
|
||||
|
||||
// Validate that it writes to the log for all requests
|
||||
var logMessage map[string]interface{}
|
||||
err := json.Unmarshal(logOutput.Bytes(), &logMessage)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(logMessage["msg"]).To(Equal("HTTP request processed"))
|
||||
Expect(logMessage["method"]).To(Equal("GET"))
|
||||
Expect(logMessage["uri"]).To(Equal("/foobar"))
|
||||
Expect(logMessage["status"]).To(Equal(404.0))
|
||||
})
|
||||
})
|
||||
130
internal/test/http_helper.go
Normal file
130
internal/test/http_helper.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
"clintonambulance.com/calculate_negative_points/internal/server"
|
||||
"github.com/onsi/gomega/ghttp"
|
||||
"github.com/swaggest/rest/web"
|
||||
)
|
||||
|
||||
var version = config.Version{Release: "test-version", Commit: "test-commit"}
|
||||
|
||||
func CreateRequestBodyFromStruct(body interface{}) *bytes.Buffer {
|
||||
return bytes.NewBuffer(CreateResponseBodyFromStruct(body))
|
||||
}
|
||||
|
||||
func CreateResponseBodyFromStruct(body interface{}) []byte {
|
||||
jsonValue, err := json.Marshal(body)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal("Error marshaling struct")
|
||||
}
|
||||
|
||||
return jsonValue
|
||||
}
|
||||
|
||||
func URLFromServerAndPath(server *ghttp.Server, path string) string {
|
||||
return fmt.Sprintf("%s%s", server.URL(), path)
|
||||
}
|
||||
|
||||
func CreateHttpServer(appConfig *config.ApplicationConfig) *web.Service {
|
||||
logger, flushLogs := config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment"})
|
||||
defer flushLogs()
|
||||
service, _ := server.NewHttpServer(logger, version)
|
||||
server.MountAllEndpoints(service, version, appConfig, logger)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// TestClaims represents the mock user claims for testing
|
||||
type TestClaims struct {
|
||||
Sub string
|
||||
Name string
|
||||
Email string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// DefaultTestClaims returns default test user claims
|
||||
func DefaultTestClaims() TestClaims {
|
||||
return TestClaims{
|
||||
Sub: "test-user-id",
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
Groups: []string{"calculate-negative-points-users"},
|
||||
}
|
||||
}
|
||||
|
||||
// addMockSessionCookie adds a mock session cookie to the request with the given claims
|
||||
func addMockSessionCookie(req *http.Request, appConfig *config.ApplicationConfig) {
|
||||
// Create a mock session with a fake id_token
|
||||
session, _ := appConfig.CookieStore.New(req, appConfig.SessionName)
|
||||
session.Values["id_token"] = "mock-test-token"
|
||||
session.Values["refresh_token"] = "mock-refresh-token"
|
||||
|
||||
// Encode the session to a cookie
|
||||
rec := httptest.NewRecorder()
|
||||
session.Save(req, rec)
|
||||
|
||||
// Copy the Set-Cookie header to the request as a Cookie header
|
||||
for _, cookie := range rec.Result().Cookies() {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
}
|
||||
|
||||
func PerformHttpRequest(method string, path string, requestParams ...map[string]interface{}) *httptest.ResponseRecorder {
|
||||
return PerformHttpRequestWithClaims(method, path, DefaultTestClaims(), requestParams...)
|
||||
}
|
||||
|
||||
func PerformHttpRequestWithClaims(method string, path string, claims TestClaims, requestParams ...map[string]interface{}) *httptest.ResponseRecorder {
|
||||
var body map[string]interface{}
|
||||
headers := map[string]string{}
|
||||
query := map[string]string{}
|
||||
appConfig := CreateTestConfig()
|
||||
srv := CreateHttpServer(appConfig)
|
||||
|
||||
for i, arg := range requestParams {
|
||||
switch i {
|
||||
case 0:
|
||||
body = arg
|
||||
case 1:
|
||||
for header, value := range arg {
|
||||
headers[header] = fmt.Sprintf("%v", value)
|
||||
}
|
||||
case 2:
|
||||
for key, value := range arg {
|
||||
query[key] = fmt.Sprintf("%v", value)
|
||||
}
|
||||
default:
|
||||
panic("Unknown argument")
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(method, path, CreateRequestBodyFromStruct(body))
|
||||
|
||||
q := req.URL.Query()
|
||||
for key, value := range query {
|
||||
q.Add(key, value)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
for header, value := range headers {
|
||||
req.Header.Set(header, value)
|
||||
}
|
||||
|
||||
// Add mock session cookie for authentication
|
||||
addMockSessionCookie(req, appConfig)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
53
internal/test/test_helper.go
Normal file
53
internal/test/test_helper.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
)
|
||||
|
||||
func CreateTestConfig() *config.ApplicationConfig {
|
||||
appSecret := "12345678901234567890123456789012"
|
||||
appSecretBlock := "09876543210987654321098765431098"
|
||||
|
||||
basepath := os.Getenv("APP_MIGRATION_DIRECTORY")
|
||||
if basepath == "" {
|
||||
_, b, _, _ := runtime.Caller(0)
|
||||
basepath = filepath.Dir(b)
|
||||
}
|
||||
|
||||
return &config.ApplicationConfig{
|
||||
AppSecret: config.SecretFromValue(appSecret),
|
||||
AppSecretBlock: config.SecretFromValue(appSecretBlock),
|
||||
CookieStore: sessions.NewCookieStore([]byte(appSecret)),
|
||||
Environment: "test",
|
||||
Listen: "127.0.0.1:3000",
|
||||
MatchThreshold: 3,
|
||||
SessionName: "calculate-negative-points-test",
|
||||
NocoDBConfig: config.NocoDBConfig{
|
||||
ApiToken: config.SecretFromValue("1234567890"),
|
||||
BaseUrl: "https://example.com",
|
||||
EmployeesTableId: "0987654321",
|
||||
InfractionsTableId: "2468013579",
|
||||
NegativeInfractionId: 1,
|
||||
NoPointsViewId: "1357924680",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func UnmarshalBody[K comparable, V any](rec *httptest.ResponseRecorder) map[K]V {
|
||||
var body map[K]V
|
||||
err := json.NewDecoder(rec.Body).Decode(&body)
|
||||
|
||||
if err != nil {
|
||||
ginkgo.Fail("Could not decode response")
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
28
internal/types/generic.go
Normal file
28
internal/types/generic.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package types
|
||||
|
||||
// PaginationRequest is a standard pagination input for API requests
|
||||
type PaginationRequest struct {
|
||||
Page int `json:"page" query:"page" default:"1" minimum:"1"`
|
||||
PageSize int `json:"page_size" query:"page_size" default:"20" minimum:"1" maximum:"100"`
|
||||
}
|
||||
|
||||
// PaginationResponse is a standard pagination output for API responses
|
||||
type PaginationResponse struct {
|
||||
Page int `json:"page" required:"true"`
|
||||
PageSize int `json:"page_size" required:"true"`
|
||||
Total int `json:"total" required:"true"`
|
||||
TotalPages int `json:"total_pages" required:"true"`
|
||||
}
|
||||
|
||||
type PaginatedIndexResponse[T any] struct {
|
||||
Items []T `json:"items" required:"true" nullable:"false"`
|
||||
Pagination PaginationResponse `json:"pagination" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type IndexResponse[T any] struct {
|
||||
Items []T `json:"items" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type ShowResponse[T any] struct {
|
||||
Item T `json:"item" required:"true" nullable:"false"`
|
||||
}
|
||||
9
internal/types/payroll_categories.go
Normal file
9
internal/types/payroll_categories.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package types
|
||||
|
||||
type UiPayrollCategory struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Rate float64 `json:"rate" required:"true" format:"double"`
|
||||
Id int `json:"id" required:"true"`
|
||||
}
|
||||
|
||||
type UiPayrollCategoriesResponse = IndexResponse[UiPayrollCategory]
|
||||
11
internal/types/payroll_entries.go
Normal file
11
internal/types/payroll_entries.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package types
|
||||
|
||||
type UiPayrollEntry struct {
|
||||
Category UiPayrollCategory `json:"category" required:"true" nullable:"false"`
|
||||
Date string `json:"date" format:"iso8601"`
|
||||
Description string `json:"description"`
|
||||
Employee UiUser `json:"employee" required:"true" nullable:"false"`
|
||||
Quantity float64 `json:"quantity" format:"double" required:"true"`
|
||||
}
|
||||
|
||||
type UiPayrollEntriesResponse = PaginatedIndexResponse[UiPayrollEntry]
|
||||
24
internal/types/users.go
Normal file
24
internal/types/users.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package types
|
||||
|
||||
import "clintonambulance.com/calculate_negative_points/internal/idms"
|
||||
|
||||
type UiUser struct {
|
||||
FirstName string `json:"first_name"`
|
||||
Groups []string `json:"groups,omitempty" required:"false"`
|
||||
Id string `json:"id" required:"true" nullable:"false"`
|
||||
LastName string `json:"last_name"`
|
||||
Name string `json:"name" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type UiUsersResponse = IndexResponse[UiUser]
|
||||
type UiUserResponse = ShowResponse[UiUser]
|
||||
|
||||
func UiUserFromIdmsUser(u idms.IdmsUser) UiUser {
|
||||
return UiUser{
|
||||
FirstName: u.FirstName(),
|
||||
Groups: u.Groups,
|
||||
Id: string(u.Attributes.EmployeeId),
|
||||
LastName: u.LastName(),
|
||||
Name: u.Name,
|
||||
}
|
||||
}
|
||||
13
internal/utils/convert_name_format.go
Normal file
13
internal/utils/convert_name_format.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package utils
|
||||
|
||||
import "strings"
|
||||
|
||||
func ConvertNameFormat(name string) string {
|
||||
parts := strings.Split(name, ",")
|
||||
if len(parts) == 2 {
|
||||
lastName := strings.TrimSpace(parts[0])
|
||||
firstName := strings.TrimSpace(parts[1])
|
||||
return firstName + " " + lastName
|
||||
}
|
||||
return name
|
||||
}
|
||||
43
internal/utils/error.go
Normal file
43
internal/utils/error.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/swaggest/rest"
|
||||
)
|
||||
|
||||
// CustomApplicationError is a marker interface that tells error middleware to serialize this error as-is, using Json schema.
|
||||
type CustomApplicationError interface {
|
||||
rest.ErrWithHTTPStatus
|
||||
CustomApplicationError()
|
||||
}
|
||||
|
||||
type ApplicationError struct {
|
||||
Message string `json:"message"`
|
||||
code int
|
||||
}
|
||||
|
||||
var (
|
||||
_ CustomApplicationError = ApplicationError{}
|
||||
)
|
||||
|
||||
func NewApplicationError(message string, code int) ApplicationError {
|
||||
return ApplicationError{
|
||||
Message: message,
|
||||
code: code,
|
||||
}
|
||||
}
|
||||
|
||||
func (e ApplicationError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e ApplicationError) HTTPStatus() int {
|
||||
return e.code
|
||||
}
|
||||
|
||||
func (e ApplicationError) CustomApplicationError() {}
|
||||
|
||||
func ValidationError(message string) ApplicationError {
|
||||
return NewApplicationError(message, http.StatusUnprocessableEntity)
|
||||
}
|
||||
20
internal/utils/first_day_of_month.go
Normal file
20
internal/utils/first_day_of_month.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package utils
|
||||
|
||||
import "time"
|
||||
|
||||
func FirstDayOfMonth() string {
|
||||
// Load America/Detroit timezone
|
||||
loc, err := time.LoadLocation("America/Detroit")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Get current time in Detroit timezone
|
||||
now := time.Now().In(loc)
|
||||
|
||||
// Create midnight of the first day of the current month
|
||||
firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
||||
|
||||
// Return ISO8601 formatted string
|
||||
return firstDay.Format(time.RFC3339)
|
||||
}
|
||||
34
internal/utils/levenshtein_distance.go
Normal file
34
internal/utils/levenshtein_distance.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package utils
|
||||
|
||||
import "strings"
|
||||
|
||||
func LevenshteinDistance(s1, s2 string) int {
|
||||
s1 = strings.ToLower(s1)
|
||||
s2 = strings.ToLower(s2)
|
||||
|
||||
len1 := len(s1)
|
||||
len2 := len(s2)
|
||||
|
||||
matrix := make([][]int, len1+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len2+1)
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := range matrix[0] {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
for i := 1; i <= len1; i++ {
|
||||
for j := 1; j <= len2; j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1,
|
||||
min(matrix[i][j-1]+1, matrix[i-1][j-1]+cost),
|
||||
)
|
||||
}
|
||||
}
|
||||
return matrix[len1][len2]
|
||||
}
|
||||
19
internal/utils/logging.go
Normal file
19
internal/utils/logging.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func LogMessageFromDatabaseOperation(entityName string, operation string, dbOp func() error) (string, error) {
|
||||
err := dbOp()
|
||||
logMsg := fmt.Sprintf("%s %sd successfully", cases.Title(language.English).String(entityName), operation)
|
||||
|
||||
if err != nil {
|
||||
logMsg = fmt.Sprintf("%s %s encountered an error", cases.Title(language.English).String(operation), entityName)
|
||||
}
|
||||
|
||||
return logMsg, err
|
||||
}
|
||||
12
internal/utils/must.go
Normal file
12
internal/utils/must.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package utils
|
||||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
func Must[T any](res T, err error) func(logger *zap.Logger) T {
|
||||
return func(logger *zap.Logger) T {
|
||||
if err != nil {
|
||||
logger.Fatal("Fatal error", zap.Error(err))
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
23
internal/utils/normalize_name.go
Normal file
23
internal/utils/normalize_name.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func NormalizeName(name string) string {
|
||||
// Remove extra whitespace
|
||||
name = strings.TrimSpace(name)
|
||||
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ")
|
||||
|
||||
// Remove common punctuation but keep hyphens
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if unicode.IsLetter(r) || unicode.IsSpace(r) || r == '-' {
|
||||
return unicode.ToLower(r)
|
||||
}
|
||||
return -1
|
||||
}, name)
|
||||
|
||||
return name
|
||||
}
|
||||
105
internal/utils/parse_xls_file.go
Normal file
105
internal/utils/parse_xls_file.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type Shift struct {
|
||||
EarningCode string `json:"earning_code"`
|
||||
Description string `json:"description"`
|
||||
Hours float64 `json:"hours"`
|
||||
}
|
||||
|
||||
func (s Shift) CountsAsWorked() bool {
|
||||
return strings.ToLower(s.EarningCode) == "reg"
|
||||
}
|
||||
|
||||
type Employee struct {
|
||||
Name string `json:"name"`
|
||||
Shifts []Shift `json:"shifts"`
|
||||
}
|
||||
|
||||
func (e Employee) Worked() bool {
|
||||
return lo.ContainsBy(e.Shifts, func(s Shift) bool { return s.CountsAsWorked() })
|
||||
}
|
||||
|
||||
func ParseUploadedXLSFile(file *multipart.FileHeader) ([]Employee, error) {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open XLS file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
content, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read XLS file: %w", err)
|
||||
}
|
||||
|
||||
return ParseXLSContent(content)
|
||||
}
|
||||
|
||||
// ParseXLSContent parses the HTML content of an XLS file and returns a slice
|
||||
// of employees with their shifts. The XLS files are HTML tables where:
|
||||
// - Column A: employee name (td with class="smallbold" and valign=top)
|
||||
// - Column B: inner table containing shift rows
|
||||
// - Within the inner table, rows with colspan are category headers (skipped)
|
||||
// - Shift rows have: spacer td, earning code/description td (class="smalltext"),
|
||||
// and hours td
|
||||
func ParseXLSContent(content []byte) ([]Employee, error) {
|
||||
html := string(content)
|
||||
|
||||
// Split by employee rows in the outer table. Each employee row contains
|
||||
// a name cell (column A) followed by a cell with an inner table (column B).
|
||||
employeePattern := regexp.MustCompile(
|
||||
`<td\s+valign=top\s+class="smallbold">([^<]+)</td>\s*<td>(.*?)</table></td>`,
|
||||
)
|
||||
employeeMatches := employeePattern.FindAllStringSubmatch(html, -1)
|
||||
|
||||
// Pattern for shift data rows: spacer td + earning code td + hours td
|
||||
// These are rows where column C has class="smalltext" (not a colspan header)
|
||||
shiftPattern := regexp.MustCompile(
|
||||
`<tr><td width=25></td><td class=smalltext>([^<]+)</td><td[^>]*>([^<]+)</td></tr>`,
|
||||
)
|
||||
|
||||
employees := lo.Map(employeeMatches, func(empMatch []string, _ int) Employee {
|
||||
name := strings.TrimSpace(empMatch[1])
|
||||
innerTable := empMatch[2]
|
||||
|
||||
shiftMatches := shiftPattern.FindAllStringSubmatch(innerTable, -1)
|
||||
|
||||
shifts := lo.FilterMap(shiftMatches, func(sm []string, _ int) (Shift, bool) {
|
||||
codeAndDesc := strings.TrimSpace(sm[1])
|
||||
hoursStr := strings.TrimSpace(sm[2])
|
||||
|
||||
parts := strings.SplitN(codeAndDesc, " - ", 2)
|
||||
if len(parts) != 2 {
|
||||
return Shift{}, false
|
||||
}
|
||||
|
||||
hours, err := strconv.ParseFloat(hoursStr, 64)
|
||||
if err != nil {
|
||||
return Shift{}, false
|
||||
}
|
||||
|
||||
return Shift{
|
||||
EarningCode: strings.TrimSpace(parts[0]),
|
||||
Description: strings.TrimSpace(parts[1]),
|
||||
Hours: hours,
|
||||
}, true
|
||||
})
|
||||
|
||||
return Employee{
|
||||
Name: name,
|
||||
Shifts: shifts,
|
||||
}
|
||||
})
|
||||
|
||||
return employees, nil
|
||||
}
|
||||
77
internal/utils/parse_xls_file_test.go
Normal file
77
internal/utils/parse_xls_file_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package utils_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/utils"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ParseXLSContent", func() {
|
||||
It("parses employees and their shifts from the test XLS file", func() {
|
||||
content, err := os.ReadFile("testdata/test.xls")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
employees, err := utils.ParseXLSContent(content)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(employees).To(HaveLen(2))
|
||||
|
||||
By("parsing the first employee")
|
||||
Expect(employees[0].Name).To(Equal("Howe, Eugene"))
|
||||
Expect(employees[0].Worked()).To(BeTrue())
|
||||
Expect(employees[0].Shifts).To(HaveLen(2))
|
||||
Expect(employees[0].Shifts[0]).To(Equal(utils.Shift{
|
||||
EarningCode: "MEET",
|
||||
Description: "Meetings",
|
||||
Hours: 1.75,
|
||||
}))
|
||||
Expect(employees[0].Shifts[0].CountsAsWorked()).To(BeFalse())
|
||||
Expect(employees[0].Shifts[1]).To(Equal(utils.Shift{
|
||||
EarningCode: "Reg",
|
||||
Description: "Regular Hours",
|
||||
Hours: 125.75,
|
||||
}))
|
||||
Expect(employees[0].Shifts[1].CountsAsWorked()).To(BeTrue())
|
||||
|
||||
By("parsing the second employee with multiple shift categories")
|
||||
Expect(employees[1].Name).To(Equal("User, Test"))
|
||||
Expect(employees[1].Shifts).To(HaveLen(4))
|
||||
Expect(employees[1].Shifts[0]).To(Equal(utils.Shift{
|
||||
EarningCode: "MEET",
|
||||
Description: "Meetings",
|
||||
Hours: 2.00,
|
||||
}))
|
||||
Expect(employees[1].Shifts[1]).To(Equal(utils.Shift{
|
||||
EarningCode: "Reg",
|
||||
Description: "Regular Hours",
|
||||
Hours: 134.00,
|
||||
}))
|
||||
Expect(employees[1].Shifts[2]).To(Equal(utils.Shift{
|
||||
EarningCode: "Holiday",
|
||||
Description: "Holiday Hours",
|
||||
Hours: 8.00,
|
||||
}))
|
||||
Expect(employees[1].Shifts[3]).To(Equal(utils.Shift{
|
||||
EarningCode: "PTO",
|
||||
Description: "Paid Time Off",
|
||||
Hours: 12.00,
|
||||
}))
|
||||
})
|
||||
|
||||
It("skips category header rows (colspan rows)", func() {
|
||||
content, err := os.ReadFile("testdata/test.xls")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
employees, err := utils.ParseXLSContent(content)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(employees).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("returns an empty slice for empty content", func() {
|
||||
employees, err := utils.ParseXLSContent([]byte(""))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(employees).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
14
internal/utils/random_string.go
Normal file
14
internal/utils/random_string.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func RandomString(n int) (string, error) {
|
||||
bytes := make([]byte, n)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
11
internal/utils/schema/openfeature_flag.go
Normal file
11
internal/utils/schema/openfeature_flag.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package schemautils
|
||||
|
||||
type OpenfeatureFlag struct {
|
||||
Name string `json:"name" required:"true" description:"Name of the flag"`
|
||||
Id string `json:"id" required:"true" description:"UUID of the flag"`
|
||||
Kind string `json:"kind" required:"true" description:"Flag kind" enum:"boolean,number,string,string_array,int_array,float_array"`
|
||||
Enabled bool `json:"enabled" required:"true" description:"This is hardcoded to true"`
|
||||
Variant string `json:"variant" required:"true" description:"The name of the variant that is assigned to the appclient environment"`
|
||||
Variants map[string]interface{} `json:"variants" required:"true" description:"All variants and their values" nullable:"false"`
|
||||
Default string `json:"default" required:"true" description:"Name of the default variant assigned to the flag"`
|
||||
}
|
||||
24
internal/utils/to_title_case.go
Normal file
24
internal/utils/to_title_case.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package utils
|
||||
|
||||
import "strings"
|
||||
|
||||
func ToTitleCase(name string) string {
|
||||
words := strings.Fields(name)
|
||||
for i, word := range words {
|
||||
if len(word) > 0 {
|
||||
// Handle hyphenated names
|
||||
if strings.Contains(word, "-") {
|
||||
parts := strings.Split(word, "-")
|
||||
for j, part := range parts {
|
||||
if len(part) > 0 {
|
||||
parts[j] = strings.ToUpper(string(part[0])) + strings.ToLower(part[1:])
|
||||
}
|
||||
}
|
||||
words[i] = strings.Join(parts, "-")
|
||||
} else {
|
||||
words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
13
internal/utils/utils_suite_test.go
Normal file
13
internal/utils/utils_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package utils_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestUtils(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Utils Suite")
|
||||
}
|
||||
37
internal/views/web.html
Normal file
37
internal/views/web.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Calculate Negative Points</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
{{ if .isDev }}
|
||||
<script type="module">
|
||||
import RefreshRuntime from "{{.baseAddress}}/@react-refresh"
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
|
||||
<script type="module" src="{{.baseAddress}}/@vite/client"></script>
|
||||
<script type="module" src="{{.jsFileAddress}}"></script>
|
||||
{{ else }}
|
||||
<script type="module" src="/{{.jsFileAddress}}"></script>
|
||||
{{ range .css }}
|
||||
<link rel="stylesheet" href="/{{.}}" />
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
160
internal/web/web.go
Normal file
160
internal/web/web.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/swaggest/rest/web"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ManifestEntry struct {
|
||||
Src string `json:"src"`
|
||||
File string `json:"file"`
|
||||
IsEntry bool `json:"isEntry"`
|
||||
Css []string `json:"css"`
|
||||
}
|
||||
|
||||
func withEntryPoint(config *config.ApplicationConfig, ep string, tmplParams map[string]interface{}) {
|
||||
|
||||
if config.Environment == "dev" {
|
||||
tmplParams["jsFileAddress"] = fmt.Sprintf("%s/%s", tmplParams["baseAddress"], ep)
|
||||
} else {
|
||||
var parsedManifest map[string]ManifestEntry
|
||||
manifestContent, err := os.ReadFile(filepath.Join(config.PublicPath, ".vite", "manifest.json"))
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(manifestContent, &parsedManifest)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
tmplParams["jsFileAddress"] = parsedManifest[ep].File
|
||||
tmplParams["css"] = parsedManifest[ep].Css
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func MountWebEndpoints(e *web.Service, config *config.ApplicationConfig, logger *zap.Logger) {
|
||||
rootResponder := func(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl, err := template.ParseFiles(filepath.Join(config.ViewPath, "web.html"))
|
||||
|
||||
tmplParams := map[string]interface{}{
|
||||
"baseAddress": "http://localhost:5173",
|
||||
"isDev": config.Environment == "dev",
|
||||
"environment": config.Environment,
|
||||
}
|
||||
|
||||
withEntryPoint(config, "src/main.tsx", tmplParams)
|
||||
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
http.Error(w, "Something went wrong", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, tmplParams); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
loggedOutResponder := func(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl, err := template.ParseFiles(filepath.Join(config.ViewPath, "logged_out.html"))
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "Something went wrong", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, &struct{}{}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
notAuthorizedResponder := func(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl, err := template.ParseFiles(filepath.Join(config.ViewPath, "auth_error.html"))
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "Something went wrong", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, &struct{}{}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
loginHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
returnTo := r.URL.Query().Get("returnTo")
|
||||
if returnTo == "" {
|
||||
returnTo = "/" // fallback
|
||||
}
|
||||
|
||||
session, _ := config.CookieStore.Get(r, config.SessionName)
|
||||
session.Values["return_to"] = returnTo
|
||||
session.Save(r, w)
|
||||
|
||||
state := "random-state" // Replace with actual CSRF protection
|
||||
http.Redirect(w, r, config.OidcConfig.OAuth2.AuthCodeURL(state), http.StatusFound)
|
||||
}
|
||||
|
||||
callbackHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
token, err := config.OidcConfig.OAuth2.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
http.Error(w, "Missing id_token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = config.OidcConfig.Verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
session, _ := config.CookieStore.Get(r, config.SessionName)
|
||||
|
||||
if refreshToken, ok := token.Extra("refresh_token").(string); ok {
|
||||
session.Values["refresh_token"] = refreshToken
|
||||
}
|
||||
|
||||
returnTo, ok := session.Values["return_to"].(string)
|
||||
if !ok || returnTo == "" {
|
||||
returnTo = "/"
|
||||
}
|
||||
session.Values["id_token"] = rawIDToken
|
||||
session.Save(r, w)
|
||||
|
||||
http.Redirect(w, r, returnTo, http.StatusFound)
|
||||
}
|
||||
|
||||
e.Wrapper.Get("/users/logged_out", loggedOutResponder)
|
||||
e.Wrapper.Get("/403", notAuthorizedResponder)
|
||||
e.Wrapper.Post("/403", notAuthorizedResponder)
|
||||
|
||||
e.Route("/", func(r chi.Router) {
|
||||
r.HandleFunc("/", rootResponder)
|
||||
r.Get("/auth/login", loginHandler)
|
||||
r.Get("/auth/callback", callbackHandler)
|
||||
r.MethodFunc(http.MethodGet, "/*", rootResponder)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user