diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 53f0161..407448a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,6 +4,7 @@ on: push: branches: - 'main' + - 'issue-33-more-groups-implementation' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/api/openapi-spec/openapi.yaml b/api/openapi-spec/openapi.yaml index bdef49a..b304a43 100644 --- a/api/openapi-spec/openapi.yaml +++ b/api/openapi-spec/openapi.yaml @@ -71,8 +71,11 @@ paths: description: |- Obtain a list of all or selected groups, including their members. - Admin or Api Key authorization: can see all groups. + Admin or Api Key authorization: can see all groups if the "show" + query parameter is set to "all". + User authorization will only show groups visible to the current user, and only allows + "show" value "public". Normal users: can only see groups visible to them. If public groups are enabled in configuration, this means all groups that are public, not full, and from which the user wasn't banned. Not all fields will be filled. @@ -80,6 +83,13 @@ paths: Note: both admins and normal users can always use the findMyGroup operation to get the group they are in. operationId: listGroups parameters: + - name: show + in: query + description: set to "all" to request admin access rather than normal visibility rules. If permissions aren't sufficient, will result in a 403. Set to "public" to request only groups with flag "public". + schema: + type: string + example: public + default: public - name: member_ids in: query description: a comma separated list of badge numbers. The result will be limited to all groups that contain at least one of these badge numbers as members. Only admins can set this parameter, it is ignored for normal attendees to avoid leaking information. @@ -94,7 +104,7 @@ paths: example: 2 - name: max_size in: query - description: list only groups that currently have at most this many members (optional, defaults to -1 which means no limitation) + description: list only groups that currently have at most this many members (optional, defaults to 0 which means no limitation) schema: type: integer example: 4 @@ -179,13 +189,13 @@ paths: schema: $ref: '#/components/schemas/Error' '403': - description: You do not have permission to create a group (maybe not registered or not in valid status for creating a group?) + description: You do not have permission to create a group (not a valid registration? not in valid status for creating a group?) content: application/json: schema: $ref: '#/components/schemas/Error' '404': - description: No such attendee (when setting owner id). + description: No such attendee (when setting owner id - admin only). content: application/json: schema: @@ -237,7 +247,7 @@ paths: schema: $ref: '#/components/schemas/Error' '403': - description: You do not have permission to see this (maybe not a valid registration?) + description: You do not have permission to see this (maybe not a valid registration? status not attending?) content: application/json: schema: @@ -335,7 +345,7 @@ paths: $ref: '#/components/schemas/Group' required: true responses: - '200': + '204': description: Successful operation headers: Location: @@ -425,6 +435,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + '409': + description: Group still contains members other than the owner. Must remove them first to ensure proper notifications. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '500': description: An unexpected error occurred. This includes database errors. A best effort attempt is made to return details in the body. content: diff --git a/cmd/reg-room-service/main.go b/cmd/reg-room-service/main.go index c995c02..c8883ff 100644 --- a/cmd/reg-room-service/main.go +++ b/cmd/reg-room-service/main.go @@ -5,7 +5,7 @@ import ( "github.com/urfave/cli/v2" - "github.com/eurofurence/reg-room-service/internal/web/app" + "github.com/eurofurence/reg-room-service/internal/application/app" ) var ( diff --git a/docs/config.example.yaml b/docs/config.example.yaml index cf2f571..674b016 100644 --- a/docs/config.example.yaml +++ b/docs/config.example.yaml @@ -1,15 +1,23 @@ -server: - port: 8081 -go_live: - public: - start_iso_datetime: 1995-06-30T11:11:11+02:00 - booking_code: Kaiser-Wilhelm-Koog - staff: - start_iso_datetime: 1995-06-29T11:11:11+02:00 - booking_code: Dithmarschen - group: staff service: + attendee_service_url: 'http://localhost:9091' # no trailing slash max_group_size: 6 + group_flags: + - public + room_flags: + - handicapped + - final +server: + port: 9094 +database: + use: 'mysql' # or inmemory + username: 'demouser' + password: 'demopw' + database: 'tcp(localhost:3306)/dbname' + parameters: + - 'charset=utf8mb4' + - 'collation=utf8mb4_general_ci' + - 'parseTime=True' + - 'timeout=30s' # connection timeout security: cors: disable: false @@ -28,3 +36,14 @@ security: cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc mwIDAQAB -----END PUBLIC KEY----- +logging: + style: ecs + severity: INFO +go_live: + public: + start_iso_datetime: 1995-06-30T11:11:11+02:00 + booking_code: Kaiser-Wilhelm-Koog + staff: + start_iso_datetime: 1995-06-29T11:11:11+02:00 + booking_code: Dithmarschen + group: staff diff --git a/internal/api/v1/apimodel.go b/internal/api/v1/apimodel.go index 573280b..e32cda5 100644 --- a/internal/api/v1/apimodel.go +++ b/internal/api/v1/apimodel.go @@ -1,5 +1,20 @@ package modelsv1 +import "time" + +var _ = time.Now + +type Error struct { + // The time at which the error occurred. + Timestamp time.Time `json:"timestamp"` + // An internal trace id assigned to the error. Used to find logs associated with errors across our services. Display to the user as something to communicate to us with inquiries about the error. + Requestid string `json:"requestid"` + // A keyed description of the error. We do not write human readable text here because the user interface will be multi language. At this time, there are these values: - auth.unauthorized (token missing completely or invalid) - auth.forbidden (permissions missing) + Message string `json:"message"` + // Optional additional details about the error. If available, will usually contain English language technobabble. + Details map[string][]string `json:"details,omitempty"` +} + type Group struct { // The internal primary key of the group, in the form of a UUID. Only set when reading groups, completely ignored when you send a group to us. ID string `yaml:"id" json:"id"` diff --git a/internal/web/app/application.go b/internal/application/app/application.go similarity index 93% rename from internal/web/app/application.go rename to internal/application/app/application.go index 384fd26..8e8750e 100644 --- a/internal/web/app/application.go +++ b/internal/application/app/application.go @@ -4,11 +4,11 @@ import ( "context" aulogging "github.com/StephanHCB/go-autumn-logging" auzerolog "github.com/StephanHCB/go-autumn-logging-zerolog" - "github.com/eurofurence/reg-room-service/internal/config" + "github.com/eurofurence/reg-room-service/internal/application/common" + "github.com/eurofurence/reg-room-service/internal/application/server" + "github.com/eurofurence/reg-room-service/internal/repository/config" "github.com/eurofurence/reg-room-service/internal/repository/database/dbrepo" "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" - "github.com/eurofurence/reg-room-service/internal/web/common" - "github.com/eurofurence/reg-room-service/internal/web/server" "github.com/rs/zerolog" ) diff --git a/internal/application/common/context.go b/internal/application/common/context.go new file mode 100644 index 0000000..6ea42ad --- /dev/null +++ b/internal/application/common/context.go @@ -0,0 +1,93 @@ +package common + +import ( + "context" + "github.com/golang-jwt/jwt/v4" +) + +type ( + CtxKeyIDToken struct{} + CtxKeyAccessToken struct{} + CtxKeyAPIKey struct{} + CtxKeyClaims struct{} + + CtxKeyRequestID struct{} + CtxKeyRequestURL struct{} + + // TODO Remove after legacy system was replaced with 2FA + // See reference https://github.com/eurofurence/reg-payment-service/issues/57 + CtxKeyAdminHeader struct{} +) + +type CustomClaims struct { + EMail string `json:"email"` + EMailVerified bool `json:"email_verified"` + Groups []string `json:"groups,omitempty"` + Name string `json:"name"` +} + +type AllClaims struct { + jwt.RegisteredClaims + CustomClaims +} + +const RequestIDKey = "RequestIDKey" + +// GetRequestID extracts the request ID from the context. +// +// It originally comes from a header with the request, or is rolled while processing +// the request. +func GetRequestID(ctx context.Context) string { + if reqID, ok := ctx.Value(RequestIDKey).(string); ok { + return reqID + } + + return "ffffffff" +} + +// GetClaims extracts all jwt token claims from the context. +func GetClaims(ctx context.Context) *AllClaims { + claims := ctx.Value(CtxKeyClaims{}) + if claims == nil { + return nil + } + + allClaims, ok := claims.(*AllClaims) + if !ok { + return nil + } + + return allClaims +} + +// GetGroups extracts the groups from the jwt token that came with the request +// or from the groups retrieved from userinfo, if using authorization token. +// +// In either case the list is filtered by relevant groups (if reg-auth-service is configured). +func GetGroups(ctx context.Context) []string { + claims := GetClaims(ctx) + if claims == nil || claims.Groups == nil { + return []string{} + } + return claims.Groups +} + +// HasGroup checks that the user has a group. +func HasGroup(ctx context.Context, group string) bool { + for _, grp := range GetGroups(ctx) { + if grp == group { + return true + } + } + return false +} + +// GetSubject extracts the subject field from the jwt token or the userinfo response, if using +// an authorization token. +func GetSubject(ctx context.Context) string { + claims := GetClaims(ctx) + if claims == nil { + return "" + } + return claims.Subject +} diff --git a/internal/application/common/errors.go b/internal/application/common/errors.go new file mode 100644 index 0000000..3213f3b --- /dev/null +++ b/internal/application/common/errors.go @@ -0,0 +1,149 @@ +package common + +import ( + "context" + modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" + "net/http" + "net/url" + "time" +) + +// APIError allows lower layers of the service to provide detailed information about an error. +// +// While this breaks layer separation somewhat, it avoids having to map errors all over the place. +type APIError interface { + error + Status() int + Response() modelsv1.Error +} + +// ErrorMessageCode is a key to use for error messages in frontends or other automated systems interacting +// with our API. It avoids having to parse human-readable language for error classification beyond the +// http status. +type ErrorMessageCode string + +const ( + AuthUnauthorized ErrorMessageCode = "auth.unauthorized" // token missing completely or invalid or expired + AuthForbidden ErrorMessageCode = "auth.forbidden" // permissions missing + RequestParseFailed ErrorMessageCode = "request.parse.failed" // Request could not be parsed properly + InternalErrorMessage ErrorMessageCode = "http.error.internal" // Internal error + UnknownErrorMessage ErrorMessageCode = "http.error.unknown" // Unknown error + + DownstreamAttSrv ErrorMessageCode = "attendee.validation.error" + NoSuchAttendee ErrorMessageCode = "attendee.notfound" + NotAttending ErrorMessageCode = "attendee.status.not.attending" + + GroupIDInvalid ErrorMessageCode = "group.id.invalid" + GroupDataInvalid ErrorMessageCode = "group.data.invalid" + GroupIDNotFound ErrorMessageCode = "group.id.notfound" + GroupReadError ErrorMessageCode = "group.read.error" + GroupWriteError ErrorMessageCode = "group.write.error" + + GroupMemberNotFound ErrorMessageCode = "group.member.notfound" +) + +// construct specific API errors + +func NewBadRequest(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusBadRequest, message, details) +} + +func NewUnauthorized(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusUnauthorized, message, details) +} + +func NewForbidden(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusForbidden, message, details) +} + +func NewNotFound(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusNotFound, message, details) +} + +func NewConflict(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusConflict, message, details) +} + +func NewInternalServerError(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusInternalServerError, message, details) +} + +func NewBadGateway(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusBadGateway, message, details) +} + +// check for API errors + +func IsBadRequestError(err error) bool { + return isAPIErrorWithStatus(http.StatusBadRequest, err) +} + +func IsUnauthorizedError(err error) bool { + return isAPIErrorWithStatus(http.StatusUnauthorized, err) +} + +func IsForbiddenError(err error) bool { + return isAPIErrorWithStatus(http.StatusForbidden, err) +} + +func IsNotFoundError(err error) bool { + return isAPIErrorWithStatus(http.StatusNotFound, err) +} + +func IsConflictError(err error) bool { + return isAPIErrorWithStatus(http.StatusConflict, err) +} + +func IsBadGatewayError(err error) bool { + return isAPIErrorWithStatus(http.StatusBadGateway, err) +} + +func IsInternalServerError(err error) bool { + return isAPIErrorWithStatus(http.StatusInternalServerError, err) +} + +func IsAPIError(err error) bool { + _, ok := err.(APIError) + return ok +} + +// NewAPIError creates a generic API error from directly provided information. +func NewAPIError(ctx context.Context, status int, message ErrorMessageCode, details url.Values) APIError { + return &StatusError{ + errStatus: status, + response: modelsv1.Error{ + Timestamp: time.Now(), + Requestid: GetRequestID(ctx), + Message: string(message), + Details: details, + }, + } +} + +var _ error = (*StatusError)(nil) + +type StatusError struct { + errStatus int + response modelsv1.Error +} + +func (se *StatusError) Error() string { + return se.response.Message +} + +func (se *StatusError) Status() int { + return se.errStatus +} + +func (se *StatusError) Response() modelsv1.Error { + return se.response +} + +func isAPIErrorWithStatus(status int, err error) bool { + apiError, ok := err.(APIError) + return ok && status == apiError.Status() +} + +func Details(details string) url.Values { + return url.Values{"details": []string{details}} +} diff --git a/internal/util/ptr/ptr.go b/internal/application/common/ptr.go similarity index 89% rename from internal/util/ptr/ptr.go rename to internal/application/common/ptr.go index 6af0220..bdb0ca5 100644 --- a/internal/util/ptr/ptr.go +++ b/internal/application/common/ptr.go @@ -1,4 +1,4 @@ -package ptr +package common func Deref[T any](ptr *T) T { var def T diff --git a/internal/web/middleware/authentication_test.go b/internal/application/middleware/authentication_test.go similarity index 98% rename from internal/web/middleware/authentication_test.go rename to internal/application/middleware/authentication_test.go index ad777aa..687e6a9 100644 --- a/internal/web/middleware/authentication_test.go +++ b/internal/application/middleware/authentication_test.go @@ -12,8 +12,8 @@ import ( "github.com/stretchr/testify/require" - "github.com/eurofurence/reg-room-service/internal/config" - "github.com/eurofurence/reg-room-service/internal/web/common" + "github.com/eurofurence/reg-room-service/internal/application/common" + "github.com/eurofurence/reg-room-service/internal/repository/config" ) const valid_JWT_is_not_staff = `eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZ2xvYmFsIjp7Im5hbWUiOiJKb2huIERvZSIsInJvbGVzIjpbXX0sImlhdCI6MTUxNjIzOTAyMn0.ove6_7BWQRe9HQyphwDdbiaAchgn9ynC4-2EYEXFeVTDADC4P3XYv5uLisYg4Mx8BZOnkWX-5L82pFO1mUZM147gLKMsYlc-iMKXy4sKZPzhQ_XKnBR-EBIf5x_ZD1wpva9ti7Yrvd0vDi8YSFdqqf7R4RA11hv9kg-_gg1uea6sK-Q_eEqoet7ocqGVLu-ghhkZdVLxu9tWJFPNueILWv8vW1Y_u9fDtfOhw7Ugf5ysI9RXiO-tXEHKN2HnFPCkwccnMFt4PJRzU1VoOldz0xzzZRb-j2tlbjLqcQkjMwLEoPQpC4Wbl8DgkaVdTi2aNyH7EbWMynlSOZIYK0AFvQ` diff --git a/internal/web/middleware/corsfilter.go b/internal/application/middleware/corsfilter.go similarity index 95% rename from internal/web/middleware/corsfilter.go rename to internal/application/middleware/corsfilter.go index b3bf402..2f2949d 100644 --- a/internal/web/middleware/corsfilter.go +++ b/internal/application/middleware/corsfilter.go @@ -4,7 +4,7 @@ import ( aulogging "github.com/StephanHCB/go-autumn-logging" "github.com/go-http-utils/headers" - "github.com/eurofurence/reg-room-service/internal/config" + "github.com/eurofurence/reg-room-service/internal/repository/config" "log" "net/http" diff --git a/internal/web/middleware/recoverer.go b/internal/application/middleware/recoverer.go similarity index 66% rename from internal/web/middleware/recoverer.go rename to internal/application/middleware/recoverer.go index f9faf5e..d8628b6 100644 --- a/internal/web/middleware/recoverer.go +++ b/internal/application/middleware/recoverer.go @@ -2,7 +2,8 @@ package middleware import ( aulogging "github.com/StephanHCB/go-autumn-logging" - "github.com/eurofurence/reg-room-service/internal/web/common" + "github.com/eurofurence/reg-room-service/internal/application/common" + "github.com/eurofurence/reg-room-service/internal/application/web" "net/http" "runtime/debug" ) @@ -15,7 +16,7 @@ func PanicRecoverer(next http.Handler) http.Handler { ctx := r.Context() stack := string(debug.Stack()) aulogging.Error(ctx, "recovered from PANIC: "+stack) - common.SendErrorWithStatusAndMessage(ctx, w, http.StatusInternalServerError, "internal.error", "") + web.SendErrorWithStatusAndMessage(ctx, w, http.StatusInternalServerError, common.InternalErrorMessage, "") } }() diff --git a/internal/web/middleware/reqid.go b/internal/application/middleware/reqid.go similarity index 88% rename from internal/web/middleware/reqid.go rename to internal/application/middleware/reqid.go index 180478c..e79403f 100644 --- a/internal/web/middleware/reqid.go +++ b/internal/application/middleware/reqid.go @@ -2,7 +2,7 @@ package middleware import ( "context" - "github.com/eurofurence/reg-room-service/internal/web/common" + "github.com/eurofurence/reg-room-service/internal/application/common" "net/http" "regexp" @@ -30,7 +30,7 @@ func RequestIdMiddleware(next http.Handler) http.Handler { } } ctx := r.Context() - newCtx := context.WithValue(ctx, common.RequestIDKey, reqUuidStr) + newCtx := context.WithValue(ctx, common.CtxKeyRequestID{}, reqUuidStr) r = r.WithContext(newCtx) w.Header().Add(RequestIDHeader, reqUuidStr) diff --git a/internal/web/middleware/requestlogging.go b/internal/application/middleware/requestlogging.go similarity index 100% rename from internal/web/middleware/requestlogging.go rename to internal/application/middleware/requestlogging.go diff --git a/internal/web/middleware/security.go b/internal/application/middleware/security.go similarity index 96% rename from internal/web/middleware/security.go rename to internal/application/middleware/security.go index 2ddbb16..7561b87 100644 --- a/internal/web/middleware/security.go +++ b/internal/application/middleware/security.go @@ -6,15 +6,16 @@ import ( "errors" "fmt" aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/eurofurence/reg-room-service/internal/application/web" "net/http" "strings" "github.com/go-http-utils/headers" "github.com/golang-jwt/jwt/v4" - "github.com/eurofurence/reg-room-service/internal/config" + "github.com/eurofurence/reg-room-service/internal/application/common" + "github.com/eurofurence/reg-room-service/internal/repository/config" "github.com/eurofurence/reg-room-service/internal/repository/downstreams/authservice" - "github.com/eurofurence/reg-room-service/internal/web/common" ) type openEndpoint struct { @@ -272,7 +273,7 @@ func CheckRequestAuthorization(conf *config.SecurityConfig) func(http.Handler) h ctx, userFacingErrorMessage, err := checkAllAuthentication(ctx, r.Method, r.URL.Path, conf, apiTokenHeaderValue, authHeaderValue, idTokenCookieValue, accessTokenCookieValue) if err != nil { aulogging.WarnErrf(ctx, err, "authorization failed: %s: %s", userFacingErrorMessage, err.Error()) - common.SendUnauthorizedResponse(ctx, w, userFacingErrorMessage) + web.SendUnauthorizedResponse(ctx, w, userFacingErrorMessage) return } diff --git a/internal/web/middleware/security_test.go b/internal/application/middleware/security_test.go similarity index 99% rename from internal/web/middleware/security_test.go rename to internal/application/middleware/security_test.go index c4fbc5a..2e0910d 100644 --- a/internal/web/middleware/security_test.go +++ b/internal/application/middleware/security_test.go @@ -12,9 +12,9 @@ import ( "github.com/stretchr/testify/require" "github.com/eurofurence/reg-room-service/docs" - "github.com/eurofurence/reg-room-service/internal/config" + "github.com/eurofurence/reg-room-service/internal/application/common" + "github.com/eurofurence/reg-room-service/internal/repository/config" "github.com/eurofurence/reg-room-service/internal/repository/downstreams/authservice" - "github.com/eurofurence/reg-room-service/internal/web/common" ) // --- test setup --- diff --git a/internal/web/v1/api.go b/internal/application/server/router.go similarity index 59% rename from internal/web/v1/api.go rename to internal/application/server/router.go index 1626123..e0c89ac 100644 --- a/internal/web/v1/api.go +++ b/internal/application/server/router.go @@ -1,21 +1,18 @@ -package v1 +package server import ( - "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" - "github.com/eurofurence/reg-room-service/internal/web/v1/health" - "net/http" - "github.com/StephanHCB/go-autumn-logging-zerolog/loggermiddleware" - "github.com/eurofurence/reg-room-service/internal/config" + "github.com/eurofurence/reg-room-service/internal/application/middleware" + "github.com/eurofurence/reg-room-service/internal/controller/v1/countdownctl" + "github.com/eurofurence/reg-room-service/internal/controller/v1/groupsctl" + "github.com/eurofurence/reg-room-service/internal/controller/v1/healthctl" + "github.com/eurofurence/reg-room-service/internal/controller/v1/roomsctl" + "github.com/eurofurence/reg-room-service/internal/repository/config" "github.com/eurofurence/reg-room-service/internal/repository/database" - "github.com/eurofurence/reg-room-service/internal/web/middleware" - - "github.com/go-chi/chi/v5" - + "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" groupservice "github.com/eurofurence/reg-room-service/internal/service/groups" - "github.com/eurofurence/reg-room-service/internal/web/v1/countdown" - "github.com/eurofurence/reg-room-service/internal/web/v1/groups" - "github.com/eurofurence/reg-room-service/internal/web/v1/rooms" + "github.com/go-chi/chi/v5" + "net/http" ) func Router(db database.Repository, attsrv attendeeservice.AttendeeService) http.Handler { @@ -33,10 +30,10 @@ func Router(db database.Repository, attsrv attendeeservice.AttendeeService) http router.Use(middleware.CorsHeadersMiddleware(&conf.Security)) router.Use(middleware.CheckRequestAuthorization(&conf.Security)) - groups.InitRoutes(router, groupservice.NewService(db, attsrv)) - rooms.InitRoutes(router, nil) - countdown.InitRoutes(router) - health.InitRoutes(router) + groupsctl.InitRoutes(router, groupservice.NewService(db, attsrv)) + roomsctl.InitRoutes(router) + countdownctl.InitRoutes(router) + healthctl.InitRoutes(router) return router } diff --git a/internal/web/server/server.go b/internal/application/server/server.go similarity index 88% rename from internal/web/server/server.go rename to internal/application/server/server.go index 17c4b89..b9ea015 100644 --- a/internal/web/server/server.go +++ b/internal/application/server/server.go @@ -4,7 +4,7 @@ import ( "context" "fmt" aulogging "github.com/StephanHCB/go-autumn-logging" - "github.com/eurofurence/reg-room-service/internal/config" + "github.com/eurofurence/reg-room-service/internal/repository/config" "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" "log" "net" @@ -17,8 +17,6 @@ import ( "github.com/eurofurence/reg-room-service/internal/repository/database" "github.com/pkg/errors" - - v1 "github.com/eurofurence/reg-room-service/internal/web/v1" ) type server struct { @@ -50,10 +48,9 @@ func NewServer(conf *config.Config, baseCtx context.Context) Server { s.ctx = baseCtx - // TODO should be in config so it is obvious what they are set to - s.idleTimeout = time.Minute - s.readTimeout = time.Minute - s.writeTimeout = time.Minute + s.idleTimeout = time.Duration(conf.Server.IdleTimeout) * time.Second + s.readTimeout = time.Duration(conf.Server.ReadTimeout) * time.Second + s.writeTimeout = time.Duration(conf.Server.WriteTimeout) * time.Second s.host = conf.Server.BaseAddress s.port = conf.Server.Port @@ -62,7 +59,7 @@ func NewServer(conf *config.Config, baseCtx context.Context) Server { } func (s *server) Serve(db database.Repository, attsrv attendeeservice.AttendeeService) error { - handler := v1.Router(db, attsrv) + handler := Router(db, attsrv) s.srv = s.newServer(handler) s.setupSignalHandler() diff --git a/internal/web/common/endpoint.go b/internal/application/web/endpoint.go similarity index 91% rename from internal/web/common/endpoint.go rename to internal/application/web/endpoint.go index 4f346ef..2e06678 100644 --- a/internal/web/common/endpoint.go +++ b/internal/application/web/endpoint.go @@ -1,13 +1,12 @@ -package common +package web import ( "context" aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/eurofurence/reg-room-service/internal/application/common" "net/http" ) -type CtxKeyRequestURL struct{} - type ( RequestHandler[Req any] func(r *http.Request, w http.ResponseWriter) (*Req, error) ResponseHandler[Res any] func(ctx context.Context, res *Res, w http.ResponseWriter) error @@ -32,7 +31,7 @@ func CreateHandler[Req, Res any](endpoint Endpoint[Req, Res], return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - ctx = context.WithValue(ctx, CtxKeyRequestURL{}, r.URL) + ctx = context.WithValue(ctx, common.CtxKeyRequestURL{}, r.URL) defer func() { err := r.Body.Close() diff --git a/internal/web/common/endpoint_test.go b/internal/application/web/endpoint_test.go similarity index 99% rename from internal/web/common/endpoint_test.go rename to internal/application/web/endpoint_test.go index 7c3e4ee..2295154 100644 --- a/internal/web/common/endpoint_test.go +++ b/internal/application/web/endpoint_test.go @@ -1,4 +1,4 @@ -package common +package web import ( "context" diff --git a/internal/util/media/constants.go b/internal/application/web/media.go similarity index 91% rename from internal/util/media/constants.go rename to internal/application/web/media.go index 4b7e4dc..7ad7c1e 100644 --- a/internal/util/media/constants.go +++ b/internal/application/web/media.go @@ -1,4 +1,4 @@ -package media +package web const ( ContentTypeApplicationJSON = "application/json" diff --git a/internal/application/web/response.go b/internal/application/web/response.go new file mode 100644 index 0000000..0af529f --- /dev/null +++ b/internal/application/web/response.go @@ -0,0 +1,85 @@ +package web + +import ( + "context" + "encoding/json" + aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/eurofurence/reg-room-service/internal/application/common" + "net/http" + "net/url" + + "github.com/pkg/errors" +) + +func EncodeToJSON(ctx context.Context, w http.ResponseWriter, obj interface{}) { + enc := json.NewEncoder(w) + + if obj != nil { + err := enc.Encode(obj) + if err != nil { + aulogging.ErrorErrf(ctx, err, "Could not encode response. [error]: %v", err) + } + } +} + +// SendErrorResponse will send HTTPStatusErrorResponse if err is common.APIError. +// +// Otherwise sends internal server error. +func SendErrorResponse(ctx context.Context, w http.ResponseWriter, err error) { + if err == nil { + aulogging.ErrorErrf(ctx, err, "nil error in web layer") + SendErrorWithStatusAndMessage(ctx, w, http.StatusInternalServerError, common.InternalErrorMessage, "an unspecified error occurred. Please check the logs - this is a bug") + return + } + + apiErr, ok := err.(common.APIError) + if !ok { + aulogging.ErrorErrf(ctx, err, "unwrapped error in web layer: %s", err.Error()) + SendErrorWithStatusAndMessage(ctx, w, http.StatusInternalServerError, common.InternalErrorMessage, "an unclassified error occurred. Please check the logs - this is a bug") + return + } + SendAPIErrorResponse(ctx, w, apiErr) +} + +// SendAPIErrorResponse will send an api error +// which contains relevant information about the failed request to the client. +// The function will also set the http status according to the provided status. +func SendAPIErrorResponse(ctx context.Context, w http.ResponseWriter, apiErr common.APIError) { + w.WriteHeader(apiErr.Status()) + + EncodeToJSON(ctx, w, apiErr.Response()) +} + +// SendErrorWithStatusAndMessage will construct an api error +// which contains relevant information about the failed request to the client +// The function will also set the http status according to the provided status. +func SendErrorWithStatusAndMessage(ctx context.Context, w http.ResponseWriter, status int, message common.ErrorMessageCode, details string) { + var detailValues url.Values + if details != "" { + aulogging.Debugf(ctx, "Request was not successful: [error]: %s", details) + detailValues = url.Values{"details": []string{details}} + } + + apiErr := common.NewAPIError(ctx, status, message, detailValues) + SendAPIErrorResponse(ctx, w, apiErr) +} + +// EncodeWithStatus will attempt to encode the provided `value` into the +// response writer `w` and will write the status header. +// If the encoding fails, the http status will not be written to the response writer +// and the function will return an error instead. +func EncodeWithStatus[T any](status int, value *T, w http.ResponseWriter) error { + err := json.NewEncoder(w).Encode(value) + if err != nil { + return errors.Wrap(err, "could not encode type into response buffer") + } + + w.WriteHeader(status) + + return nil +} + +// SendUnauthorizedResponse sends a standardized StatusUnauthorized response to the client. +func SendUnauthorizedResponse(ctx context.Context, w http.ResponseWriter, details string) { + SendErrorWithStatusAndMessage(ctx, w, http.StatusUnauthorized, common.AuthUnauthorized, details) +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go deleted file mode 100644 index 89fc1ed..0000000 --- a/internal/config/defaults.go +++ /dev/null @@ -1,7 +0,0 @@ -package config - -func (c *Config) AddDefaults() { - if c.Server.Port == 0 { - c.Server.Port = 8081 - } -} diff --git a/internal/controller/controller.go b/internal/controller/controller.go deleted file mode 100644 index 774c2be..0000000 --- a/internal/controller/controller.go +++ /dev/null @@ -1,17 +0,0 @@ -package controller - -import "github.com/eurofurence/reg-room-service/internal/repository/database" - -// Controller is the service interface, which defines -// the functions in the service layer of this application -// -// A type implementing this interface provides functionality -// to interact between the web layer and the data layer. -// -// Deprecated: controller package will be removed in the future. -type Controller interface { -} - -type serviceController struct { - DB database.Repository -} diff --git a/internal/web/v1/countdown/countdown_routes.go b/internal/controller/v1/countdownctl/countdown.go similarity index 65% rename from internal/web/v1/countdown/countdown_routes.go rename to internal/controller/v1/countdownctl/countdown.go index abd9701..56e4ff4 100644 --- a/internal/web/v1/countdown/countdown_routes.go +++ b/internal/controller/v1/countdownctl/countdown.go @@ -1,13 +1,14 @@ -package countdown +package countdownctl import ( - "net/http" - + "github.com/eurofurence/reg-room-service/internal/application/web" "github.com/go-chi/chi/v5" - - "github.com/eurofurence/reg-room-service/internal/web/common" + "net/http" ) +// Handler implements methods, which satisfy the endpoint format. +type Handler struct{} + func InitRoutes(router chi.Router) { h := &Handler{} @@ -20,7 +21,7 @@ func initGetRoutes(router chi.Router, h *Handler) { router.Method( http.MethodGet, "/", - common.CreateHandler( + web.CreateHandler( h.GetCountdown, h.GetCountdownRequest, h.GetCountdownResponse, diff --git a/internal/web/v1/countdown/countdown_get.go b/internal/controller/v1/countdownctl/countdown_get.go similarity index 86% rename from internal/web/v1/countdown/countdown_get.go rename to internal/controller/v1/countdownctl/countdown_get.go index 0a16d1e..59e6dc3 100644 --- a/internal/web/v1/countdown/countdown_get.go +++ b/internal/controller/v1/countdownctl/countdown_get.go @@ -1,11 +1,11 @@ -package countdown +package countdownctl import ( "context" aulogging "github.com/StephanHCB/go-autumn-logging" - "github.com/eurofurence/reg-room-service/internal/config" - apierrors "github.com/eurofurence/reg-room-service/internal/errors" - "github.com/eurofurence/reg-room-service/internal/web/common" + "github.com/eurofurence/reg-room-service/internal/application/common" + "github.com/eurofurence/reg-room-service/internal/application/web" + "github.com/eurofurence/reg-room-service/internal/repository/config" "math" "net/http" "time" @@ -31,7 +31,7 @@ func (*Handler) GetCountdown(ctx context.Context, req *GetCountdownRequest, w ht conf, err := config.GetApplicationConfig() if conf == nil || err != nil { aulogging.Warn(ctx, "application config not found - failing request") - return nil, apierrors.NewInternalServerError(common.InternalErrorMessage, "configuration missing") + return nil, common.NewInternalServerError(ctx, common.InternalErrorMessage, common.Details("configuration missing")) } if req.mockTime != nil { @@ -61,7 +61,7 @@ func (*Handler) GetCountdownRequest(r *http.Request, w http.ResponseWriter) (*Ge aulogging.Warn(ctx, "mock time specified") mockTime, err := time.Parse(mockTimeFormat, currentTimeIsoParam) if err != nil { - common.SendHTTPStatusErrorResponse(r.Context(), w, apierrors.NewBadRequest(common.RequestParseErrorMessage, "mock time specified but failed to parse")) + web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details("mock time specified but failed to parse"))) return nil, err } return &GetCountdownRequest{mockTime: &mockTime}, nil @@ -71,7 +71,7 @@ func (*Handler) GetCountdownRequest(r *http.Request, w http.ResponseWriter) (*Ge } func (*Handler) GetCountdownResponse(ctx context.Context, res *modelsv1.Countdown, w http.ResponseWriter) error { - return common.EncodeWithStatus(http.StatusOK, res, w) + return web.EncodeWithStatus(http.StatusOK, res, w) } func countdown(current time.Time, target time.Time, secret string) *modelsv1.Countdown { diff --git a/internal/web/v1/groups/groupcontroller.go b/internal/controller/v1/groupsctl/groupcontroller.go similarity index 87% rename from internal/web/v1/groups/groupcontroller.go rename to internal/controller/v1/groupsctl/groupcontroller.go index 7ef44ae..66435fe 100644 --- a/internal/web/v1/groups/groupcontroller.go +++ b/internal/controller/v1/groupsctl/groupcontroller.go @@ -1,13 +1,12 @@ -package groups +package groupsctl import ( + "github.com/eurofurence/reg-room-service/internal/application/web" "net/http" groupservice "github.com/eurofurence/reg-room-service/internal/service/groups" "github.com/go-chi/chi/v5" - - "github.com/eurofurence/reg-room-service/internal/web/common" ) // Controller implements methods which satisfy the endpoint format @@ -34,7 +33,7 @@ func initGetRoutes(router chi.Router, h *Controller) { router.Method( http.MethodGet, "/", - common.CreateHandler( + web.CreateHandler( h.ListGroups, h.ListGroupsRequest, h.ListGroupsResponse, @@ -44,7 +43,7 @@ func initGetRoutes(router chi.Router, h *Controller) { router.Method( http.MethodGet, "/my", - common.CreateHandler( + web.CreateHandler( h.FindMyGroup, h.FindMyGroupRequest, h.FindMyGroupResponse, @@ -54,7 +53,7 @@ func initGetRoutes(router chi.Router, h *Controller) { router.Method( http.MethodGet, "/{uuid}", - common.CreateHandler( + web.CreateHandler( h.FindGroupByID, h.FindGroupByIDRequest, h.FindGroupByIDResponse, @@ -66,7 +65,7 @@ func initPostRoutes(router chi.Router, h *Controller) { router.Method( http.MethodPost, "/", - common.CreateHandler( + web.CreateHandler( h.CreateGroup, h.CreateGroupRequest, h.CreateGroupResponse, @@ -76,7 +75,7 @@ func initPostRoutes(router chi.Router, h *Controller) { router.Method( http.MethodPost, "/{uuid}/members/{badgenumber}", - common.CreateHandler( + web.CreateHandler( h.AddMemberToGroup, h.AddMemberToGroupRequest, h.AddMemberToGroupResponse, @@ -88,7 +87,7 @@ func initPutRoutes(router chi.Router, h *Controller) { router.Method( http.MethodPut, "/{uuid}", - common.CreateHandler( + web.CreateHandler( h.UpdateGroup, h.UpdateGroupRequest, h.UpdateGroupResponse, @@ -100,7 +99,7 @@ func initDeleteRoutes(router chi.Router, h *Controller) { router.Method( http.MethodDelete, "/{uuid}", - common.CreateHandler( + web.CreateHandler( h.DeleteGroup, h.DeleteGroupRequest, h.DeleteGroupResponse, @@ -110,7 +109,7 @@ func initDeleteRoutes(router chi.Router, h *Controller) { router.Method( http.MethodDelete, "/{uuid}/members/{badgenumber}", - common.CreateHandler( + web.CreateHandler( h.RemoveGroupMember, h.RemoveGroupMemberRequest, h.RemoveGroupMemberResponse, diff --git a/internal/web/v1/groups/groups_delete.go b/internal/controller/v1/groupsctl/groups_delete.go similarity index 89% rename from internal/web/v1/groups/groups_delete.go rename to internal/controller/v1/groupsctl/groups_delete.go index d16e2df..dc92548 100644 --- a/internal/web/v1/groups/groups_delete.go +++ b/internal/controller/v1/groupsctl/groups_delete.go @@ -1,14 +1,14 @@ -package groups +package groupsctl import ( "context" + "github.com/eurofurence/reg-room-service/internal/application/web" + "github.com/eurofurence/reg-room-service/internal/controller/v1/util" "github.com/go-chi/chi/v5" "net/http" modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" - apierrors "github.com/eurofurence/reg-room-service/internal/errors" - "github.com/eurofurence/reg-room-service/internal/web/common" - "github.com/eurofurence/reg-room-service/internal/web/v1/util" + "github.com/eurofurence/reg-room-service/internal/application/common" ) // DeleteGroupRequest holds information, which is required to call the DeleteGroup operation. @@ -82,9 +82,9 @@ func (h *Controller) RemoveGroupMemberRequest(r *http.Request, w http.ResponseWr badgeNumber, err := util.ParseUInt[uint](chi.URLParam(r, badeNumberParam)) if err != nil { - common.SendHTTPStatusErrorResponse(r.Context(), - w, - apierrors.NewBadRequest(common.GroupDataInvalidMessage, "invalid type for badge number")) + ctx := r.Context() + web.SendErrorResponse(ctx, w, + common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid type for badge number"))) return nil, err } diff --git a/internal/web/v1/groups/groups_get.go b/internal/controller/v1/groupsctl/groups_get.go similarity index 66% rename from internal/web/v1/groups/groups_get.go rename to internal/controller/v1/groupsctl/groups_get.go index 16d46ec..9493ad6 100644 --- a/internal/web/v1/groups/groups_get.go +++ b/internal/controller/v1/groupsctl/groups_get.go @@ -1,17 +1,17 @@ -package groups +package groupsctl import ( "context" - "errors" + "github.com/eurofurence/reg-room-service/internal/application/web" + "github.com/eurofurence/reg-room-service/internal/controller/v1/util" "net/http" + "net/url" "github.com/go-chi/chi/v5" "github.com/google/uuid" modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" - apierrors "github.com/eurofurence/reg-room-service/internal/errors" - "github.com/eurofurence/reg-room-service/internal/web/common" - "github.com/eurofurence/reg-room-service/internal/web/v1/util" + "github.com/eurofurence/reg-room-service/internal/application/common" ) type ListGroupsRequest struct { @@ -23,7 +23,7 @@ type ListGroupsRequest struct { func (h *Controller) ListGroups(ctx context.Context, req *ListGroupsRequest, w http.ResponseWriter) (*modelsv1.GroupList, error) { groups, err := h.svc.FindGroups(ctx, req.MinSize, req.MaxSize, req.MemberIDs) if err != nil { - common.SendErrorResponse(ctx, w, err) + web.SendErrorResponse(ctx, w, err) return nil, err } @@ -41,7 +41,7 @@ func (h *Controller) ListGroupsRequest(r *http.Request, w http.ResponseWriter) ( queryIDs := query.Get("member_ids") memberIDs, err := util.ParseMemberIDs(queryIDs) if err != nil { - common.SendHTTPStatusErrorResponse(ctx, w, apierrors.NewBadRequest("group.data.invalid", err.Error())) + web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details(err.Error()))) return nil, err } @@ -49,7 +49,7 @@ func (h *Controller) ListGroupsRequest(r *http.Request, w http.ResponseWriter) ( if minSize := query.Get("min_size"); minSize != "" { val, err := util.ParseUInt[uint](minSize) if err != nil { - common.SendHTTPStatusErrorResponse(ctx, w, apierrors.NewBadRequest("group.data.invalid", err.Error())) + web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details(err.Error()))) return nil, err } @@ -59,11 +59,11 @@ func (h *Controller) ListGroupsRequest(r *http.Request, w http.ResponseWriter) ( if maxSize := query.Get("max_size"); maxSize != "" { val, err := util.ParseInt[int](maxSize) if err != nil { - common.SendHTTPStatusErrorResponse(ctx, w, apierrors.NewBadRequest("group.data.invalid", err.Error())) + web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details(err.Error()))) return nil, err } if val < -1 { - common.SendHTTPStatusErrorResponse(ctx, w, apierrors.NewBadRequest("group.data.invalid", "maxSize cannot be less than -1")) + web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("maxSize cannot be less than -1"))) return nil, err } @@ -76,23 +76,28 @@ func (h *Controller) ListGroupsRequest(r *http.Request, w http.ResponseWriter) ( } func (h *Controller) ListGroupsResponse(ctx context.Context, res *modelsv1.GroupList, w http.ResponseWriter) error { - return common.EncodeWithStatus(http.StatusOK, res, w) + return web.EncodeWithStatus(http.StatusOK, res, w) } type FindMyGroupRequest struct{} -// FindMyGroup TODO. func (h *Controller) FindMyGroup(ctx context.Context, req *FindMyGroupRequest, w http.ResponseWriter) (*modelsv1.Group, error) { - return nil, nil + group, err := h.svc.FindMyGroup(ctx) + if err != nil { + web.SendErrorResponse(ctx, w, err) + return nil, err + } + + return group, nil } func (h *Controller) FindMyGroupRequest(r *http.Request, w http.ResponseWriter) (*FindMyGroupRequest, error) { // Endpoint only requires JWT token for now. - return nil, nil + return &FindMyGroupRequest{}, nil } func (h *Controller) FindMyGroupResponse(ctx context.Context, res *modelsv1.Group, w http.ResponseWriter) error { - return nil + return web.EncodeWithStatus(http.StatusOK, res, w) } type FindGroupByIDRequest struct { @@ -102,14 +107,8 @@ type FindGroupByIDRequest struct { func (h *Controller) FindGroupByID(ctx context.Context, req *FindGroupByIDRequest, w http.ResponseWriter) (*modelsv1.Group, error) { grp, err := h.svc.GetGroupByID(ctx, req.GroupID) if err != nil { - var statusErr apierrors.APIStatus - if !errors.As(err, &statusErr) { - common.SendHTTPStatusErrorResponse(ctx, w, apierrors.NewInternalServerError(common.InternalErrorMessage, err.Error())) - return nil, err - } - - common.SendHTTPStatusErrorResponse(ctx, w, statusErr) - return nil, statusErr + web.SendErrorResponse(ctx, w, err) + return nil, err } return grp, nil @@ -118,7 +117,8 @@ func (h *Controller) FindGroupByID(ctx context.Context, req *FindGroupByIDReques func (h *Controller) FindGroupByIDRequest(r *http.Request, w http.ResponseWriter) (*FindGroupByIDRequest, error) { groupID := chi.URLParam(r, "uuid") if _, err := uuid.Parse(groupID); err != nil { - common.SendHTTPStatusErrorResponse(r.Context(), w, apierrors.NewBadRequest("group.id.invalid", "")) + ctx := r.Context() + web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupIDInvalid, url.Values{})) return nil, err } @@ -130,5 +130,5 @@ func (h *Controller) FindGroupByIDRequest(r *http.Request, w http.ResponseWriter } func (h *Controller) FindGroupByIDResponse(_ context.Context, res *modelsv1.Group, w http.ResponseWriter) error { - return common.EncodeWithStatus(http.StatusOK, res, w) + return web.EncodeWithStatus(http.StatusOK, res, w) } diff --git a/internal/web/v1/groups/groups_post.go b/internal/controller/v1/groupsctl/groups_post.go similarity index 77% rename from internal/web/v1/groups/groups_post.go rename to internal/controller/v1/groupsctl/groups_post.go index d63cf0d..7fd32a4 100644 --- a/internal/web/v1/groups/groups_post.go +++ b/internal/controller/v1/groupsctl/groups_post.go @@ -1,16 +1,16 @@ -package groups +package groupsctl import ( "context" aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/eurofurence/reg-room-service/internal/application/web" + "github.com/eurofurence/reg-room-service/internal/controller/v1/util" "net/http" "net/url" "path" modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" - apierrors "github.com/eurofurence/reg-room-service/internal/errors" - "github.com/eurofurence/reg-room-service/internal/web/common" - "github.com/eurofurence/reg-room-service/internal/web/v1/util" + "github.com/eurofurence/reg-room-service/internal/application/common" ) type CreateGroupRequest struct { @@ -29,14 +29,14 @@ type CreateGroupRequest struct { func (h *Controller) CreateGroup(ctx context.Context, req *CreateGroupRequest, w http.ResponseWriter) (*modelsv1.Empty, error) { newGroupUUID, err := h.svc.CreateGroup(ctx, req.Group) if err != nil { - common.SendErrorResponse(ctx, w, err) + web.SendErrorResponse(ctx, w, err) return nil, err } requestURL, ok := ctx.Value(common.CtxKeyRequestURL{}).(*url.URL) if !ok { aulogging.Error(ctx, "could not retrieve base URL from context") - common.SendErrorResponse(ctx, w, nil) + web.SendErrorResponse(ctx, w, nil) return nil, nil } @@ -48,8 +48,9 @@ func (h *Controller) CreateGroupRequest(r *http.Request, w http.ResponseWriter) var group modelsv1.GroupCreate if err := util.NewStrictJSONDecoder(r.Body).Decode(&group); err != nil { - common.SendHTTPStatusErrorResponse(r.Context(), w, apierrors.NewBadRequest( - "group.data.invalid", "please check if your provided JSON is valid", + ctx := r.Context() + web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, + common.GroupDataInvalid, common.Details("please check if your provided JSON is valid"), )) return nil, err } diff --git a/internal/web/v1/groups/groups_put.go b/internal/controller/v1/groupsctl/groups_put.go similarity index 64% rename from internal/web/v1/groups/groups_put.go rename to internal/controller/v1/groupsctl/groups_put.go index 06e946c..829115e 100644 --- a/internal/web/v1/groups/groups_put.go +++ b/internal/controller/v1/groupsctl/groups_put.go @@ -1,20 +1,18 @@ -package groups +package groupsctl import ( "context" - "errors" "fmt" aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/eurofurence/reg-room-service/internal/application/web" + "github.com/eurofurence/reg-room-service/internal/controller/v1/util" "github.com/go-chi/chi/v5" "github.com/google/uuid" "net/http" "net/url" - apierrors "github.com/eurofurence/reg-room-service/internal/errors" - "github.com/eurofurence/reg-room-service/internal/web/common" - "github.com/eurofurence/reg-room-service/internal/web/v1/util" - modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" + "github.com/eurofurence/reg-room-service/internal/application/common" ) type UpdateGroupRequest struct { @@ -26,13 +24,7 @@ type UpdateGroupRequest struct { // Admins or the current group owner can change the group owner to any member of the group. func (h *Controller) UpdateGroup(ctx context.Context, req *UpdateGroupRequest, w http.ResponseWriter) (*modelsv1.Empty, error) { if err := h.svc.UpdateGroup(ctx, req.Group); err != nil { - var statusErr apierrors.APIStatus - if errors.As(err, &statusErr) { - common.SendHTTPStatusErrorResponse(ctx, w, statusErr) - return nil, err - } - - common.SendHTTPStatusErrorResponse(ctx, w, apierrors.NewInternalServerError(common.InternalErrorMessage, "unexpected error when updating group")) + web.SendErrorResponse(ctx, w, err) return nil, err } @@ -48,16 +40,18 @@ func (h *Controller) UpdateGroup(ctx context.Context, req *UpdateGroupRequest, w } func (h *Controller) UpdateGroupRequest(r *http.Request, w http.ResponseWriter) (*UpdateGroupRequest, error) { + ctx := r.Context() + groupID := chi.URLParam(r, "uuid") if err := uuid.Validate(groupID); err != nil { - common.SendHTTPStatusErrorResponse(r.Context(), w, apierrors.NewBadRequest(common.GroupIDInvalidMessage, fmt.Sprintf("%q is not a vailid UUID", groupID))) + web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupIDInvalid, common.Details(fmt.Sprintf("%q is not a vailid UUID", groupID)))) return nil, err } var group modelsv1.Group if err := util.NewStrictJSONDecoder(r.Body).Decode(&group); err != nil { - common.SendHTTPStatusErrorResponse(r.Context(), w, apierrors.NewBadRequest(common.GroupDataInvalidMessage, "invalid json provided")) + web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid json provided"))) return nil, err } diff --git a/internal/web/v1/groups/members_post.go b/internal/controller/v1/groupsctl/members_post.go similarity index 79% rename from internal/web/v1/groups/members_post.go rename to internal/controller/v1/groupsctl/members_post.go index fd685e2..a9ac590 100644 --- a/internal/web/v1/groups/members_post.go +++ b/internal/controller/v1/groupsctl/members_post.go @@ -1,7 +1,9 @@ -package groups +package groupsctl import ( "context" + "github.com/eurofurence/reg-room-service/internal/application/web" + "github.com/eurofurence/reg-room-service/internal/controller/v1/util" groupservice "github.com/eurofurence/reg-room-service/internal/service/groups" "net/http" @@ -9,9 +11,7 @@ import ( "github.com/google/uuid" modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" - apierrors "github.com/eurofurence/reg-room-service/internal/errors" - "github.com/eurofurence/reg-room-service/internal/web/common" - "github.com/eurofurence/reg-room-service/internal/web/v1/util" + "github.com/eurofurence/reg-room-service/internal/application/common" ) // AddMemberToGroup adds an attendee to a group. @@ -33,16 +33,18 @@ func (h *Controller) AddMemberToGroup(ctx context.Context, req *groupservice.Add // AddMemberToGroupRequest validates and creates the request for the AddMemberToGroup operation. func (h *Controller) AddMemberToGroupRequest(r *http.Request, w http.ResponseWriter) (*groupservice.AddGroupMemberParams, error) { + ctx := r.Context() + groupID := chi.URLParam(r, "uuid") if _, err := uuid.Parse(groupID); err != nil { - common.SendHTTPStatusErrorResponse(r.Context(), w, apierrors.NewBadRequest(common.GroupIDInvalidMessage, "")) + web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupIDInvalid, nil)) return nil, err } badge := chi.URLParam(r, "badgenumber") badgeNumber, err := util.ParseUInt[uint](badge) if err != nil { - common.SendHTTPStatusErrorResponse(r.Context(), w, apierrors.NewBadRequest(common.GroupDataInvalidMessage, "invalid type for badge number")) + web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid type for badge number"))) return nil, err } @@ -56,7 +58,7 @@ func (h *Controller) AddMemberToGroupRequest(r *http.Request, w http.ResponseWri force, err := util.ParseOptionalBool(query.Get("force")) if err != nil { - common.SendHTTPStatusErrorResponse(r.Context(), w, apierrors.NewBadRequest(common.GroupDataInvalidMessage, "")) + web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, nil)) return nil, err } diff --git a/internal/controller/v1/groupsctl/utils.go b/internal/controller/v1/groupsctl/utils.go new file mode 100644 index 0000000..c86395b --- /dev/null +++ b/internal/controller/v1/groupsctl/utils.go @@ -0,0 +1,22 @@ +package groupsctl + +import ( + "context" + "fmt" + "github.com/eurofurence/reg-room-service/internal/application/common" + "github.com/eurofurence/reg-room-service/internal/application/web" + "github.com/google/uuid" + "net/http" +) + +func validateGroupID(ctx context.Context, w http.ResponseWriter, groupID string) error { + if err := uuid.Validate(groupID); err != nil { + web.SendErrorResponse(ctx, w, + common.NewBadRequest(ctx, common.GroupIDInvalid, common.Details(fmt.Sprintf("%q is not a vailid UUID", groupID))), + ) + + return err + } + + return nil +} diff --git a/internal/web/v1/health/health_routes.go b/internal/controller/v1/healthctl/health.go similarity index 63% rename from internal/web/v1/health/health_routes.go rename to internal/controller/v1/healthctl/health.go index ac08006..cb04483 100644 --- a/internal/web/v1/health/health_routes.go +++ b/internal/controller/v1/healthctl/health.go @@ -1,13 +1,14 @@ -package health +package healthctl import ( - "net/http" - + "github.com/eurofurence/reg-room-service/internal/application/web" "github.com/go-chi/chi/v5" - - "github.com/eurofurence/reg-room-service/internal/web/common" + "net/http" ) +// Handler implements methods, which satisfy the endpoint format. +type Handler struct{} + func InitRoutes(router chi.Router) { h := &Handler{} @@ -20,7 +21,7 @@ func initGetRoutes(router chi.Router, h *Handler) { router.Method( http.MethodGet, "/", - common.CreateHandler( + web.CreateHandler( h.GetHealth, h.GetHealthRequest, h.GetHealthResponse, diff --git a/internal/web/v1/health/health_get.go b/internal/controller/v1/healthctl/health_get.go similarity index 97% rename from internal/web/v1/health/health_get.go rename to internal/controller/v1/healthctl/health_get.go index 724a9b0..3a5f8e8 100644 --- a/internal/web/v1/health/health_get.go +++ b/internal/controller/v1/healthctl/health_get.go @@ -1,4 +1,4 @@ -package health +package healthctl import ( "context" diff --git a/internal/web/v1/rooms/rooms_routes.go b/internal/controller/v1/roomsctl/rooms.go similarity index 78% rename from internal/web/v1/rooms/rooms_routes.go rename to internal/controller/v1/roomsctl/rooms.go index d95473f..7e26c1f 100644 --- a/internal/web/v1/rooms/rooms_routes.go +++ b/internal/controller/v1/roomsctl/rooms.go @@ -1,18 +1,16 @@ -package rooms +package roomsctl import ( - "net/http" - + "github.com/eurofurence/reg-room-service/internal/application/web" "github.com/go-chi/chi/v5" - - "github.com/eurofurence/reg-room-service/internal/controller" - "github.com/eurofurence/reg-room-service/internal/web/common" + "net/http" ) -func InitRoutes(router chi.Router, ctrl controller.Controller) { - h := &Handler{ - ctrl: ctrl, - } +// Handler implements methods, which satisfy the endpoint format. +type Handler struct{} + +func InitRoutes(router chi.Router) { + h := &Handler{} router.Route("/rooms", func(sr chi.Router) { initGetRoutes(sr, h) @@ -26,7 +24,7 @@ func initGetRoutes(router chi.Router, h *Handler) { router.Method( http.MethodGet, "/", - common.CreateHandler( + web.CreateHandler( h.ListRooms, h.ListRoomsRequest, h.ListRoomsResponse, @@ -36,7 +34,7 @@ func initGetRoutes(router chi.Router, h *Handler) { router.Method( http.MethodGet, "/my", - common.CreateHandler( + web.CreateHandler( h.FindMyRooom, h.FindMyRoomRequest, h.FindMyRoomResponse, @@ -46,7 +44,7 @@ func initGetRoutes(router chi.Router, h *Handler) { router.Method( http.MethodGet, "/{uuid}", - common.CreateHandler( + web.CreateHandler( h.FindRoomByUUID, h.FindRoomByUUIDRequest, h.FindRoomByUUIDResponse, @@ -58,7 +56,7 @@ func initPostRoutes(router chi.Router, h *Handler) { router.Method( http.MethodPost, "/", - common.CreateHandler( + web.CreateHandler( h.CreateRoom, h.CreateRoomRequest, h.CreateRoomResponse, @@ -68,7 +66,7 @@ func initPostRoutes(router chi.Router, h *Handler) { router.Method( http.MethodPost, "/{uuid}/individuals/{badgenumber}", - common.CreateHandler( + web.CreateHandler( h.AddRoomMember, h.AddRoomMemberRequest, h.AddRoomMemberResponse, @@ -78,7 +76,7 @@ func initPostRoutes(router chi.Router, h *Handler) { router.Method( http.MethodPost, "/{uuid}/groups/{groupid}", - common.CreateHandler( + web.CreateHandler( h.AddGroup, h.AddGroupRequest, h.AddGroupResponse, @@ -90,7 +88,7 @@ func initPutRoutes(router chi.Router, h *Handler) { router.Method( http.MethodPut, "/{uuid}", - common.CreateHandler( + web.CreateHandler( h.UpdateRoom, h.UpdateRoomRequest, h.UpdateRoomResponse, @@ -102,7 +100,7 @@ func initDeleteRoutes(router chi.Router, h *Handler) { router.Method( http.MethodDelete, "/{uuid}", - common.CreateHandler( + web.CreateHandler( h.DeleteRoom, h.DeleteRoomRequest, h.DeleteRoomResponse, @@ -112,7 +110,7 @@ func initDeleteRoutes(router chi.Router, h *Handler) { router.Method( http.MethodDelete, "/{uuid}/individuals/{badgenumber}", - common.CreateHandler( + web.CreateHandler( h.DeleteRoomMember, h.DeleteRoomMemberRequest, h.DeleteRoomMemberResponse, @@ -122,7 +120,7 @@ func initDeleteRoutes(router chi.Router, h *Handler) { router.Method( http.MethodDelete, "/{uuid}/groups/{groupid}", - common.CreateHandler( + web.CreateHandler( h.DeleteGroup, h.DeleteGroupRequest, h.DeleteGroupResponse, diff --git a/internal/web/v1/rooms/rooms_delete.go b/internal/controller/v1/roomsctl/rooms_delete.go similarity index 99% rename from internal/web/v1/rooms/rooms_delete.go rename to internal/controller/v1/roomsctl/rooms_delete.go index 0db8d50..f74489d 100644 --- a/internal/web/v1/rooms/rooms_delete.go +++ b/internal/controller/v1/roomsctl/rooms_delete.go @@ -1,4 +1,4 @@ -package rooms +package roomsctl import ( "context" diff --git a/internal/web/v1/rooms/rooms_get.go b/internal/controller/v1/roomsctl/rooms_get.go similarity index 99% rename from internal/web/v1/rooms/rooms_get.go rename to internal/controller/v1/roomsctl/rooms_get.go index 5e5a3ac..2386059 100644 --- a/internal/web/v1/rooms/rooms_get.go +++ b/internal/controller/v1/roomsctl/rooms_get.go @@ -1,4 +1,4 @@ -package rooms +package roomsctl import ( "context" diff --git a/internal/web/v1/rooms/rooms_post.go b/internal/controller/v1/roomsctl/rooms_post.go similarity index 99% rename from internal/web/v1/rooms/rooms_post.go rename to internal/controller/v1/roomsctl/rooms_post.go index 49557fb..8e8cc71 100644 --- a/internal/web/v1/rooms/rooms_post.go +++ b/internal/controller/v1/roomsctl/rooms_post.go @@ -1,4 +1,4 @@ -package rooms +package roomsctl import ( "context" diff --git a/internal/web/v1/rooms/rooms_put.go b/internal/controller/v1/roomsctl/rooms_put.go similarity index 98% rename from internal/web/v1/rooms/rooms_put.go rename to internal/controller/v1/roomsctl/rooms_put.go index e17ac66..e2ccff7 100644 --- a/internal/web/v1/rooms/rooms_put.go +++ b/internal/controller/v1/roomsctl/rooms_put.go @@ -1,4 +1,4 @@ -package rooms +package roomsctl import ( "context" diff --git a/internal/web/v1/util/util.go b/internal/controller/v1/util/util.go similarity index 94% rename from internal/web/v1/util/util.go rename to internal/controller/v1/util/util.go index aba3c43..6fff32d 100644 --- a/internal/web/v1/util/util.go +++ b/internal/controller/v1/util/util.go @@ -123,3 +123,12 @@ func ParseFloat[E Float](s string) (E, error) { return E(parsed), nil } + +func SliceContains[T comparable](needle T, haystack []T) bool { + for _, e := range haystack { + if e == needle { + return true + } + } + return false +} diff --git a/internal/web/v1/util/util_test.go b/internal/controller/v1/util/util_test.go similarity index 100% rename from internal/web/v1/util/util_test.go rename to internal/controller/v1/util/util_test.go diff --git a/internal/errors/errors.go b/internal/errors/errors.go deleted file mode 100644 index d1b9376..0000000 --- a/internal/errors/errors.go +++ /dev/null @@ -1,202 +0,0 @@ -package errors - -import ( - "errors" - "fmt" - "net/http" -) - -var _ error = (*StatusError)(nil) - -type KnownReason string - -const ( - KnownReasonBadRequest KnownReason = "BadRequest" - KnownReasonUnauthorized KnownReason = "Unauthorized" - KnownReasonForbidden KnownReason = "Forbidden" - KnownReasonNotFound KnownReason = "NotFound" - KnownReasonConflict KnownReason = "Conflict" - KnownReasonInternalServerError KnownReason = "InternalServerError" - KnownReasonBadGateway KnownReason = "BadGateway" - KnownReasonUnknown KnownReason = "Unknown" -) - -type Status struct { - Reason KnownReason - Code int - Message string - Details string -} - -type StatusError struct { - ErrStatus Status -} - -type APIStatus interface { - error - Status() Status -} - -func (se *StatusError) Error() string { - return fmt.Sprintf("%s - %s", se.Status().Message, se.Status().Details) -} - -func (se *StatusError) Status() Status { - return se.ErrStatus -} - -// NewBadRequest creates a new StatusError with error code 400. -func NewBadRequest(message, details string) APIStatus { - return &StatusError{ - ErrStatus: Status{ - Reason: KnownReasonBadRequest, - Code: http.StatusBadRequest, - Message: message, - Details: details, - }, - } -} - -// NewUnauthorized creates a new StatusError with error code 401. -func NewUnauthorized(message, details string) APIStatus { - return &StatusError{ - ErrStatus: Status{ - Reason: KnownReasonUnauthorized, - Code: http.StatusUnauthorized, - Message: message, - Details: details, - }, - } -} - -// NewForbidden creates a new StatusError with error code 403. -func NewForbidden(message, details string) APIStatus { - return &StatusError{ - ErrStatus: Status{ - Reason: KnownReasonForbidden, - Code: http.StatusForbidden, - Message: message, - Details: details, - }, - } -} - -// NewNotFound creates a new StatusError with error code 404. -func NewNotFound(message, details string) APIStatus { - return &StatusError{ - ErrStatus: Status{ - Reason: KnownReasonNotFound, - Code: http.StatusNotFound, - Message: message, - Details: details, - }, - } -} - -// NewConflict creates a new StatusError with error code 409. -func NewConflict(message, details string) APIStatus { - return &StatusError{ - ErrStatus: Status{ - Reason: KnownReasonConflict, - Code: http.StatusConflict, - Message: message, - Details: details, - }, - } -} - -// NewBadGateway creates a new StatusError with error code 502. -func NewBadGateway(message, details string) APIStatus { - return &StatusError{ - ErrStatus: Status{ - Reason: KnownReasonBadGateway, - Code: http.StatusBadGateway, - Message: message, - Details: details, - }, - } -} - -// NewInternalServerError creates a new StatusError with error code 500. -func NewInternalServerError(message, details string) APIStatus { - return &StatusError{ - ErrStatus: Status{ - Reason: KnownReasonInternalServerError, - Code: http.StatusInternalServerError, - Message: message, - Details: details, - }, - } -} - -func isReasonOrCodeForError(expectedReason KnownReason, status int, err error) bool { - errReason, code := reasonAndStatusCode(err) - - if errReason == expectedReason { - return true - } - - if code == status { - return true - } - - return false -} - -// IsBadRequestError checks if error is of type `bad request`. -func IsBadRequestError(err error) bool { - return isReasonOrCodeForError(KnownReasonBadRequest, http.StatusBadRequest, err) -} - -// IsUnauthorizedError checks if error is of type `unauthorized`. -func IsUnauthorizedError(err error) bool { - return isReasonOrCodeForError(KnownReasonUnauthorized, http.StatusUnauthorized, err) -} - -// IsForbiddenError checks if error is of type `forbidden`. -func IsForbiddenError(err error) bool { - return isReasonOrCodeForError(KnownReasonForbidden, http.StatusForbidden, err) -} - -// IsNotFoundError checks if error is of type `not found`. -func IsNotFoundError(err error) bool { - return isReasonOrCodeForError(KnownReasonNotFound, http.StatusNotFound, err) -} - -// IsConflictError checks if error is of type `conflict`. -func IsConflictError(err error) bool { - return isReasonOrCodeForError(KnownReasonConflict, http.StatusConflict, err) -} - -// IsInternalServerError checks if error is of type `internal server error`. -func IsInternalServerError(err error) bool { - return isReasonOrCodeForError(KnownReasonInternalServerError, http.StatusInternalServerError, err) -} - -// IsBadGatewayError checks if error is of type `bad gateway`. -func IsBadGatewayError(err error) bool { - return isReasonOrCodeForError(KnownReasonBadGateway, http.StatusBadGateway, err) -} - -// IsUnknownError checks if error is of type `unknown`. -func IsUnknownError(err error) bool { - return isReasonOrCodeForError(KnownReasonUnknown, 0, err) -} - -// AsAPIStatus checks if the error is of type `APIStatus` -// and returns nil if not. -func AsAPIStatus(err error) APIStatus { - if status, ok := err.(APIStatus); ok || errors.As(err, &status) { - return status - } - - return nil -} - -func reasonAndStatusCode(err error) (KnownReason, int) { - if status := AsAPIStatus(err); status != nil { - return status.Status().Reason, status.Status().Code - } - - return KnownReasonUnknown, 0 -} diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go deleted file mode 100644 index 2a6f978..0000000 --- a/internal/errors/errors_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package errors - -// TODO Write tests for apierror package diff --git a/internal/config/config.go b/internal/repository/config/config.go similarity index 94% rename from internal/config/config.go rename to internal/repository/config/config.go index 07800ba..3ea5021 100644 --- a/internal/config/config.go +++ b/internal/repository/config/config.go @@ -38,8 +38,10 @@ type ( // ServiceConfig contains configuration values // for service related tasks. E.g. URL to attendee service. ServiceConfig struct { - AttendeeServiceURL string `yaml:"attendee_service_url"` - MaxGroupSize uint `yaml:"max_group_size"` + AttendeeServiceURL string `yaml:"attendee_service_url"` + MaxGroupSize uint `yaml:"max_group_size"` + GroupFlags []string `yaml:"group_flags"` + RoomFlags []string `yaml:"room_flags"` } // ServerConfig contains all values for diff --git a/internal/repository/config/defaults.go b/internal/repository/config/defaults.go new file mode 100644 index 0000000..19f118e --- /dev/null +++ b/internal/repository/config/defaults.go @@ -0,0 +1,16 @@ +package config + +func (c *Config) AddDefaults() { + if c.Server.Port == 0 { + c.Server.Port = 8081 + } + if c.Server.IdleTimeout <= 0 { + c.Server.IdleTimeout = 30 + } + if c.Server.ReadTimeout <= 0 { + c.Server.ReadTimeout = 30 + } + if c.Server.WriteTimeout <= 0 { + c.Server.WriteTimeout = 30 + } +} diff --git a/internal/config/validate.go b/internal/repository/config/validate.go similarity index 100% rename from internal/config/validate.go rename to internal/repository/config/validate.go diff --git a/internal/repository/downstreams/attendeeservice/client.go b/internal/repository/downstreams/attendeeservice/client.go index 6c4d398..6bf2509 100644 --- a/internal/repository/downstreams/attendeeservice/client.go +++ b/internal/repository/downstreams/attendeeservice/client.go @@ -6,7 +6,7 @@ import ( "fmt" "net/http" - "github.com/eurofurence/reg-room-service/internal/config" + "github.com/eurofurence/reg-room-service/internal/repository/config" aurestclientapi "github.com/StephanHCB/go-autumn-restclient/api" diff --git a/internal/repository/downstreams/attendeeservice/mock.go b/internal/repository/downstreams/attendeeservice/mock.go index 2e99187..b3ff2c9 100644 --- a/internal/repository/downstreams/attendeeservice/mock.go +++ b/internal/repository/downstreams/attendeeservice/mock.go @@ -3,8 +3,8 @@ package attendeeservice import ( "context" "errors" + "github.com/eurofurence/reg-room-service/internal/application/common" "github.com/eurofurence/reg-room-service/internal/repository/downstreams" - "github.com/eurofurence/reg-room-service/internal/web/common" ) type Mock interface { diff --git a/internal/repository/downstreams/authservice/client.go b/internal/repository/downstreams/authservice/client.go index fd0e4a7..e6e4072 100644 --- a/internal/repository/downstreams/authservice/client.go +++ b/internal/repository/downstreams/authservice/client.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/eurofurence/reg-room-service/internal/config" + "github.com/eurofurence/reg-room-service/internal/repository/config" aurestbreaker "github.com/StephanHCB/go-autumn-restclient-circuitbreaker/implementation/breaker" aurestclientapi "github.com/StephanHCB/go-autumn-restclient/api" diff --git a/internal/repository/downstreams/authservice/instance.go b/internal/repository/downstreams/authservice/instance.go index 96c54e2..339d0e9 100644 --- a/internal/repository/downstreams/authservice/instance.go +++ b/internal/repository/downstreams/authservice/instance.go @@ -3,7 +3,7 @@ package authservice import ( aulogging "github.com/StephanHCB/go-autumn-logging" - "github.com/eurofurence/reg-room-service/internal/config" + "github.com/eurofurence/reg-room-service/internal/repository/config" ) var activeInstance AuthService diff --git a/internal/repository/downstreams/authservice/mock.go b/internal/repository/downstreams/authservice/mock.go index f82a7a9..43f2c05 100644 --- a/internal/repository/downstreams/authservice/mock.go +++ b/internal/repository/downstreams/authservice/mock.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/eurofurence/reg-room-service/internal/web/common" + "github.com/eurofurence/reg-room-service/internal/application/common" ) type Mock interface { diff --git a/internal/repository/downstreams/common.go b/internal/repository/downstreams/common.go index bcfe7c1..8627bc6 100644 --- a/internal/repository/downstreams/common.go +++ b/internal/repository/downstreams/common.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/eurofurence/reg-room-service/internal/config" + "github.com/eurofurence/reg-room-service/internal/repository/config" aurestlogging "github.com/StephanHCB/go-autumn-restclient/implementation/requestlogging" "github.com/go-chi/chi/v5/middleware" @@ -16,7 +16,7 @@ import ( auresthttpclient "github.com/StephanHCB/go-autumn-restclient/implementation/httpclient" "github.com/go-http-utils/headers" - "github.com/eurofurence/reg-room-service/internal/web/common" + "github.com/eurofurence/reg-room-service/internal/application/common" ) // nolint diff --git a/internal/service/groups/groups.go b/internal/service/groups/groups.go index 5ca5b67..4f1c1cd 100644 --- a/internal/service/groups/groups.go +++ b/internal/service/groups/groups.go @@ -5,27 +5,19 @@ import ( "errors" "fmt" aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/eurofurence/reg-room-service/internal/controller/v1/util" "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" + "net/url" "slices" "strings" "gorm.io/gorm" modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" + "github.com/eurofurence/reg-room-service/internal/application/common" "github.com/eurofurence/reg-room-service/internal/entity" - apierrors "github.com/eurofurence/reg-room-service/internal/errors" "github.com/eurofurence/reg-room-service/internal/repository/database" "github.com/eurofurence/reg-room-service/internal/service/rbac" - "github.com/eurofurence/reg-room-service/internal/util/ptr" - "github.com/eurofurence/reg-room-service/internal/web/common" - "github.com/eurofurence/reg-room-service/internal/web/v1/util" -) - -var ( - errGroupIDNotFound = apierrors.NewNotFound(common.GroupIDNotFoundMessage, "unable to find group in database") - errGroupHasNoMembers = apierrors.NewInternalServerError(common.GroupMemberNotFound, "unable to find members in group") - errCouldNotGetValidator = apierrors.NewInternalServerError(common.InternalErrorMessage, "unexpected error when parsing user claims") - errNotAttending = apierrors.NewForbidden(common.NotAttending, "access denied - you must have a valid registration in status approved, (partially) paid, checked in") ) // Service defines the interface for the service function implementations for the group endpoints. @@ -36,6 +28,7 @@ type Service interface { DeleteGroup(ctx context.Context, groupID string) error AddMemberToGroup(ctx context.Context, req AddGroupMemberParams) error FindGroups(ctx context.Context, minSize uint, maxSize int, memberIDs []uint) ([]*modelsv1.Group, error) + FindMyGroup(ctx context.Context) (*modelsv1.Group, error) } func NewService(repository database.Repository, attsrv attendeeservice.AttendeeService) Service { @@ -50,6 +43,32 @@ type groupService struct { AttSrv attendeeservice.AttendeeService } +// FindMyGroup finds the group containing the currently logged in attendee. +// +// This even works for admins. +// +// Uses the attendee service to look up the badge number. +func (g *groupService) FindMyGroup(ctx context.Context) (*modelsv1.Group, error) { + myID, err := g.loggedInUserValidRegistrationBadgeNo(ctx) + if err != nil { + return nil, err + } + + groups, err := g.findGroupsLowlevel(ctx, 0, -1, []uint{uint(myID)}) + if err != nil { + return nil, err + } + + if len(groups) == 0 { + return nil, errNoGroup(ctx) + } + if len(groups) > 1 { + return nil, errInternal(ctx, "multiple group memberships found - this is a bug") + } + + return groups[0], nil +} + // FindGroups finds groups by size (number of members) and member badge numbers. // // A group matches if its size is in the range (maxSize -1 means no limit), and if it @@ -64,7 +83,7 @@ func (g *groupService) FindGroups(ctx context.Context, minSize uint, maxSize int validator, err := rbac.NewValidator(ctx) if err != nil { aulogging.ErrorErrf(ctx, err, "Could not retrieve RBAC validator from context. [error]: %v", err) - return make([]*modelsv1.Group, 0), errCouldNotGetValidator + return make([]*modelsv1.Group, 0), errCouldNotGetValidator(ctx) } if validator.IsAdmin() || validator.IsAPITokenCall() { @@ -96,7 +115,7 @@ func (g *groupService) FindGroups(ctx context.Context, minSize uint, maxSize int return result, nil } else { - return make([]*modelsv1.Group, 0), errNotAttending // shouldn't ever happen, just in case + return make([]*modelsv1.Group, 0), errNotAttending(ctx) // shouldn't ever happen, just in case } } @@ -110,7 +129,7 @@ func (g *groupService) findGroupsLowlevel(ctx context.Context, minSize uint, max } aulogging.ErrorErrf(ctx, err, "find groups failed: %s", err.Error()) - return result, apierrors.NewInternalServerError(common.InternalErrorMessage, "database error while finding groups - see logs for details") + return result, errInternal(ctx, "database error while finding groups - see logs for details") } for _, id := range groupIDs { @@ -118,7 +137,7 @@ func (g *groupService) findGroupsLowlevel(ctx context.Context, minSize uint, max if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { aulogging.WarnErrf(ctx, err, "find groups failed to read group %s - maybe intermittent change: %s", id, err.Error()) - return make([]*modelsv1.Group, 0), apierrors.NewInternalServerError(common.InternalErrorMessage, "database error while finding groups - see logs for details") + return make([]*modelsv1.Group, 0), errInternal(ctx, "database error while finding groups - see logs for details") } } @@ -133,16 +152,16 @@ func (g *groupService) GetGroupByID(ctx context.Context, groupID string) (*model grp, err := g.DB.GetGroupByID(ctx, groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errGroupIDNotFound + return nil, errGroupIDNotFound(ctx) } - return nil, apierrors.NewInternalServerError(common.InternalErrorMessage, err.Error()) + return nil, errGroupRead(ctx, err.Error()) } groupMembers, err := g.DB.GetGroupMembersByGroupID(ctx, groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errGroupHasNoMembers + return nil, errGroupHasNoMembers(ctx) } } @@ -151,7 +170,7 @@ func (g *groupService) GetGroupByID(ctx context.Context, groupID string) (*model Name: grp.Name, Flags: aggregateFlags(grp.Flags), Comments: &grp.Comments, - MaximumSize: ptr.To(int32(grp.MaximumSize)), + MaximumSize: common.To(int32(grp.MaximumSize)), Owner: int32(grp.Owner), Members: toMembers(groupMembers), Invites: nil, // TODO @@ -166,7 +185,7 @@ func (g *groupService) CreateGroup(ctx context.Context, group modelsv1.GroupCrea validator, err := rbac.NewValidator(ctx) if err != nil { aulogging.ErrorErrf(ctx, err, "Could not retrieve RBAC validator from context. [error]: %v", err) - return "", errCouldNotGetValidator + return "", errCouldNotGetValidator(ctx) } var ownerID uint @@ -181,11 +200,16 @@ func (g *groupService) CreateGroup(ctx context.Context, group modelsv1.GroupCrea ownerID = uint(myID) } + validation := validateGroup(group) + if len(validation) > 0 { + return "", common.NewBadRequest(ctx, common.GroupDataInvalid, validation) + } + // Create a new group in the database groupID, err := g.DB.AddGroup(ctx, &entity.Group{ Name: group.Name, Flags: fmt.Sprintf(",%s,", strings.Join(group.Flags, ",")), - Comments: ptr.Deref(group.Comments), + Comments: common.Deref(group.Comments), MaximumSize: maxGroupSize(), Owner: ownerID, }) @@ -198,6 +222,23 @@ func (g *groupService) CreateGroup(ctx context.Context, group modelsv1.GroupCrea return groupID, g.DB.AddGroupMembership(ctx, gm) } +func validateGroup(group modelsv1.GroupCreate) url.Values { + result := url.Values{} + if len(group.Name) == 0 { + result.Set("name", "group name cannot be empty") + } + if len(group.Name) > 50 { + result.Set("name", "group name too long, max 50 characters") + } + allowed := allowedFlags() + for _, flag := range group.Flags { + if !util.SliceContains(flag, allowed) { + result.Set("flags", fmt.Sprintf("no such flag '%s'", url.PathEscape(flag))) + } + } + return result +} + // AddGroupMemberParams is the request type for the AddMemberToGroup operation. type AddGroupMemberParams struct { // GroupID is the ID of the group where a user should be added @@ -220,7 +261,7 @@ func (g *groupService) AddMemberToGroup(ctx context.Context, req AddGroupMemberP err := g.DB.AddGroupMembership(ctx, gm) if err != nil { - return apierrors.NewInternalServerError(common.InternalErrorMessage, err.Error()) + return errInternal(ctx, err.Error()) } return nil @@ -233,19 +274,19 @@ func (g *groupService) UpdateGroup(ctx context.Context, group modelsv1.Group) er validator, err := rbac.NewValidator(ctx) if err != nil { aulogging.ErrorErrf(ctx, err, "Could not retrieve RBAC validator from context. [error]: %v", err) - return apierrors.NewInternalServerError(common.InternalErrorMessage, "unexpected error when parsing user claims") + return errInternal(ctx, "unexpected error when parsing user claims") } badgeNumber, err := util.ParseUInt[uint](validator.Subject()) if err != nil { aulogging.WarnErrf(ctx, err, "subject has an unexpected value %q", validator.Subject()) - return apierrors.NewInternalServerError(common.InternalErrorMessage, "subject should have a valid numerical value") + return errInternal(ctx, "subject should have a valid numerical value") } getGroup, err := g.DB.GetGroupByID(ctx, group.ID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return errGroupIDNotFound + return errGroupIDNotFound(ctx) } } @@ -253,8 +294,8 @@ func (g *groupService) UpdateGroup(ctx context.Context, group modelsv1.Group) er Base: entity.Base{ID: group.ID}, Name: group.Name, Flags: fmt.Sprintf(",%s,", strings.Join(group.Flags, ",")), - Comments: ptr.Deref(group.Comments), - MaximumSize: uint(ptr.Deref(group.MaximumSize)), + Comments: common.Deref(group.Comments), + MaximumSize: uint(common.Deref(group.MaximumSize)), } // Changes to the group owner can only be instigated by either the group owner @@ -282,10 +323,10 @@ func (g *groupService) changeGroupOwner(ctx context.Context, group modelsv1.Grou members, err := g.DB.GetGroupMembersByGroupID(ctx, group.ID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return errGroupHasNoMembers + return errGroupHasNoMembers(ctx) } aulogging.ErrorErrf(ctx, err, "unexpected error %v", err) - return apierrors.NewInternalServerError(common.InternalErrorMessage, "unexpected error occurrec") + return errInternal(ctx, "unexpected error occurrec") } found := false for _, member := range members { @@ -295,7 +336,7 @@ func (g *groupService) changeGroupOwner(ctx context.Context, group modelsv1.Grou } } if !found { - return errGroupHasNoMembers + return errGroupHasNoMembers(ctx) } updateGroup.Owner = uint(group.Owner) return nil @@ -306,18 +347,16 @@ func (g *groupService) DeleteGroup(ctx context.Context, groupID string) error { validator, err := rbac.NewValidator(ctx) if err != nil { aulogging.ErrorErrf(ctx, err, "Could not retrieve RBAC validator from context. [error]: %v", err) - return apierrors.NewInternalServerError(common.InternalErrorMessage, "unexpected error when parsing user claims") + return errInternal(ctx, "unexpected error when parsing user claims") } group, err := g.DB.GetGroupByID(ctx, groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return apierrors.NewNotFound(common.GroupIDNotFoundMessage, - fmt.Sprintf("couldn't find group for ID: %s", groupID)) + return errGroupIDNotFound(ctx) } - return apierrors.NewInternalServerError(common.InternalErrorMessage, - fmt.Sprintf("error when retrieving group with ID: %s", groupID)) + return errGroupRead(ctx, "error retrieving group - see logs for details") } if group.DeletedAt.Valid { @@ -329,38 +368,37 @@ func (g *groupService) DeleteGroup(ctx context.Context, groupID string) error { badgeNumber, err := util.ParseUInt[uint](validator.Subject()) if err != nil { aulogging.ErrorErrf(ctx, err, "subject has an unexpected value %q", validator.Subject()) - return apierrors.NewInternalServerError(common.InternalErrorMessage, "subject should have a valid numerical value") + return errInternal(ctx, "subject should have a valid numerical value - this is a bug, please report it") } if !validator.IsAdmin() || badgeNumber == group.Owner { - return apierrors.NewForbidden(common.AuthForbiddenMessage, "only the group owner or an admin can delete a group") + return common.NewForbidden(ctx, common.AuthForbidden, common.Details("only the group owner or an admin can delete a group")) } members, err := g.DB.GetGroupMembersByGroupID(ctx, groupID) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return apierrors.NewInternalServerError(common.GroupMemberNotFound, "at least one group member should be in this group") + if !errors.Is(err, gorm.ErrRecordNotFound) { + return errInternal(ctx, "failed to read group members during delete") } + // empty group is ok } // first we have to remove all members, which have been part of the group and then for _, member := range members { if err := g.DB.DeleteGroupMembership(ctx, member.ID); err != nil { aulogging.ErrorErrf(ctx, err, "error occurred when trying to remove member with ID %d from group %s. [error]: %s", member.ID, groupID, err.Error()) - return apierrors.NewInternalServerError( - common.InternalErrorMessage, + return errInternal(ctx, fmt.Sprintf("could not remove member %d from group %s", member.ID, groupID)) } } if err := g.DB.SoftDeleteGroupByID(ctx, groupID); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return apierrors.NewNotFound(common.GroupIDNotFoundMessage, fmt.Sprintf("couldn't find group with ID %s", groupID)) + return errGroupIDNotFound(ctx) } aulogging.ErrorErrf(ctx, err, "unexpected error. [error]: %s", err.Error()) - return apierrors.NewInternalServerError( - common.InternalErrorMessage, "unexpected error occurred during deletion of group") + return errInternal(ctx, "unexpected error occurred during deletion of group") } return nil @@ -403,3 +441,35 @@ func aggregateFlags(input string) []string { return tags } + +func errNoGroup(ctx context.Context) error { + return common.NewNotFound(ctx, common.GroupMemberNotFound, common.Details("not in a group")) +} + +func errGroupIDNotFound(ctx context.Context) error { + return common.NewNotFound(ctx, common.GroupIDNotFound, common.Details("unable to find group in database")) +} + +func errGroupHasNoMembers(ctx context.Context) error { + return common.NewInternalServerError(ctx, common.GroupMemberNotFound, common.Details("unable to find members in group")) +} + +func errCouldNotGetValidator(ctx context.Context) error { + return common.NewInternalServerError(ctx, common.InternalErrorMessage, common.Details("unexpected error when parsing user claims")) +} + +func errNotAttending(ctx context.Context) error { + return common.NewForbidden(ctx, common.NotAttending, common.Details("access denied - you must have a valid registration in status approved, (partially) paid, checked in")) +} + +func errGroupRead(ctx context.Context, details string) error { + return common.NewInternalServerError(ctx, common.GroupReadError, common.Details(details)) +} + +func errGroupWrite(ctx context.Context, details string) error { + return common.NewInternalServerError(ctx, common.GroupWriteError, common.Details(details)) +} + +func errInternal(ctx context.Context, details string) error { + return common.NewInternalServerError(ctx, common.InternalErrorMessage, common.Details(details)) +} diff --git a/internal/service/groups/helpers.go b/internal/service/groups/helpers.go index 122687f..a6896ec 100644 --- a/internal/service/groups/helpers.go +++ b/internal/service/groups/helpers.go @@ -4,21 +4,20 @@ import ( "context" aulogging "github.com/StephanHCB/go-autumn-logging" modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" - "github.com/eurofurence/reg-room-service/internal/config" - apierrors "github.com/eurofurence/reg-room-service/internal/errors" + "github.com/eurofurence/reg-room-service/internal/application/common" + "github.com/eurofurence/reg-room-service/internal/repository/config" "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" - "github.com/eurofurence/reg-room-service/internal/web/common" ) func (g *groupService) loggedInUserValidRegistrationBadgeNo(ctx context.Context) (int64, error) { myRegIDs, err := g.AttSrv.ListMyRegistrationIds(ctx) if err != nil { aulogging.WarnErrf(ctx, err, "failed to obtain registrations for currently logged in user: %s", err.Error()) - return 0, apierrors.NewBadGateway(common.DownstreamAttSrv, "downstream error when contacting attendee service") + return 0, common.NewBadGateway(ctx, common.DownstreamAttSrv, common.Details("downstream error when contacting attendee service")) } if len(myRegIDs) == 0 { - aulogging.InfoErr(ctx, err, "currently logged in user has no registrations - cannot create a group") - return 0, apierrors.NewNotFound(common.NoSuchAttendee, "you do not have a valid registration") + aulogging.InfoErr(ctx, err, "currently logged in user has no registrations - cannot be in a group") + return 0, common.NewForbidden(ctx, common.NoSuchAttendee, common.Details("you do not have a valid registration")) } myID := myRegIDs[0] @@ -32,14 +31,14 @@ func (g *groupService) checkAttending(ctx context.Context, badgeNo int64) error myStatus, err := g.AttSrv.GetStatus(ctx, badgeNo) if err != nil { aulogging.WarnErrf(ctx, err, "failed to obtain status for badge number %d: %s", badgeNo, err.Error()) - return apierrors.NewBadGateway(common.DownstreamAttSrv, "downstream error when contacting attendee service") + return common.NewBadGateway(ctx, common.DownstreamAttSrv, common.Details("downstream error when contacting attendee service")) } switch myStatus { case attendeeservice.StatusApproved, attendeeservice.StatusPartiallyPaid, attendeeservice.StatusPaid, attendeeservice.StatusCheckedIn: return nil default: - return apierrors.NewForbidden(common.NotAttending, "registration is not in attending status") + return common.NewForbidden(ctx, common.NotAttending, common.Details("registration is not in attending status")) } } @@ -51,6 +50,14 @@ func maxGroupSize() uint { return conf.Service.MaxGroupSize } +func allowedFlags() []string { + conf, err := config.GetApplicationConfig() + if err != nil { + panic("configuration not loaded before call to allowedFlags() - this is a bug") + } + return conf.Service.GroupFlags +} + func publicInfo(grp *modelsv1.Group, myID int32) *modelsv1.Group { if grp == nil { return nil diff --git a/internal/service/rbac/rbac.go b/internal/service/rbac/rbac.go index 47b4d41..473c825 100644 --- a/internal/service/rbac/rbac.go +++ b/internal/service/rbac/rbac.go @@ -3,8 +3,8 @@ package rbac import ( "context" - "github.com/eurofurence/reg-room-service/internal/config" - "github.com/eurofurence/reg-room-service/internal/web/common" + "github.com/eurofurence/reg-room-service/internal/application/common" + "github.com/eurofurence/reg-room-service/internal/repository/config" ) type CtxKeyValidator struct{} diff --git a/internal/service/rbac/rbac_test.go b/internal/service/rbac/rbac_test.go index 3b6bef2..492a19c 100644 --- a/internal/service/rbac/rbac_test.go +++ b/internal/service/rbac/rbac_test.go @@ -9,8 +9,8 @@ import ( "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/require" - "github.com/eurofurence/reg-room-service/internal/config" - "github.com/eurofurence/reg-room-service/internal/web/common" + "github.com/eurofurence/reg-room-service/internal/application/common" + "github.com/eurofurence/reg-room-service/internal/repository/config" ) // note: there is a TestMain that loads configuration diff --git a/internal/web/common/common.go b/internal/web/common/common.go deleted file mode 100644 index 44ac442..0000000 --- a/internal/web/common/common.go +++ /dev/null @@ -1,183 +0,0 @@ -package common - -import ( - "context" - "encoding/json" - aulogging "github.com/StephanHCB/go-autumn-logging" - "net/http" - "net/url" - - "github.com/golang-jwt/jwt/v4" - "github.com/pkg/errors" - - apierrors "github.com/eurofurence/reg-room-service/internal/errors" -) - -type ( - CtxKeyIDToken struct{} - CtxKeyAccessToken struct{} - CtxKeyAPIKey struct{} - CtxKeyClaims struct{} - - // TODO Remove after legacy system was replaced with 2FA - // See reference https://github.com/eurofurence/reg-payment-service/issues/57 - CtxKeyAdminHeader struct{} -) - -type CustomClaims struct { - EMail string `json:"email"` - EMailVerified bool `json:"email_verified"` - Groups []string `json:"groups,omitempty"` - Name string `json:"name"` -} - -type AllClaims struct { - jwt.RegisteredClaims - CustomClaims -} - -const RequestIDKey = "RequestIDKey" - -// GetRequestID extracts the request ID from the context. -// -// It originally comes from a header with the request, or is rolled while processing -// the request. -func GetRequestID(ctx context.Context) string { - if reqID, ok := ctx.Value(RequestIDKey).(string); ok { - return reqID - } - - return "ffffffff" -} - -// GetClaims extracts all jwt token claims from the context. -func GetClaims(ctx context.Context) *AllClaims { - claims := ctx.Value(CtxKeyClaims{}) - if claims == nil { - return nil - } - - allClaims, ok := claims.(*AllClaims) - if !ok { - return nil - } - - return allClaims -} - -// GetGroups extracts the groups from the jwt token that came with the request -// or from the groups retrieved from userinfo, if using authorization token. -// -// In either case the list is filtered by relevant groups (if reg-auth-service is configured). -func GetGroups(ctx context.Context) []string { - claims := GetClaims(ctx) - if claims == nil || claims.Groups == nil { - return []string{} - } - return claims.Groups -} - -// HasGroup checks that the user has a group. -func HasGroup(ctx context.Context, group string) bool { - for _, grp := range GetGroups(ctx) { - if grp == group { - return true - } - } - return false -} - -// GetSubject extracts the subject field from the jwt token or the userinfo response, if using -// an authorization token. -func GetSubject(ctx context.Context) string { - claims := GetClaims(ctx) - if claims == nil { - return "" - } - return claims.Subject -} - -func EncodeToJSON(ctx context.Context, w http.ResponseWriter, obj interface{}) { - enc := json.NewEncoder(w) - - if obj != nil { - err := enc.Encode(obj) - if err != nil { - aulogging.ErrorErrf(ctx, err, "Could not encode response. [error]: %v", err) - } - } -} - -// SendErrorResponse will send HTTPStatusErrorResponse if err is apierrors.APIStatus. -// -// Otherwise sends internal server error. -func SendErrorResponse(ctx context.Context, w http.ResponseWriter, err error) { - if err == nil { - aulogging.ErrorErrf(ctx, err, "nil error in web layer") - SendErrorWithStatusAndMessage(ctx, w, http.StatusInternalServerError, InternalErrorMessage, "an unspecified error occurred. Please check the logs - this is a bug") - return - } - - status, ok := err.(apierrors.APIStatus) - if !ok { - aulogging.ErrorErrf(ctx, err, "unwrapped error in web layer: %s", err.Error()) - SendErrorWithStatusAndMessage(ctx, w, http.StatusInternalServerError, InternalErrorMessage, "an unclassified error occurred. Please check the logs - this is a bug") - return - } - SendHTTPStatusErrorResponse(ctx, w, status) -} - -// SendHTTPStatusErrorResponse will send an api error -// which contains relevant information about the failed request to the client. -// The function will also set the http status according to the provided status. -func SendHTTPStatusErrorResponse(ctx context.Context, w http.ResponseWriter, status apierrors.APIStatus) { - reqID := GetRequestID(ctx) - w.WriteHeader(status.Status().Code) - - var detailValues url.Values - details := status.Status().Details - if details != "" { - aulogging.Debugf(ctx, "Request was not successful: [error]: %s", details) - detailValues = url.Values{"details": []string{details}} - } - - apiErr := NewAPIError(reqID, status.Status().Message, detailValues) - EncodeToJSON(ctx, w, apiErr) -} - -// SendErrorWithStatusAndMessage will construct an api error -// which contains relevant information about the failed request to the client -// The function will also set the http status according to the provided status. -func SendErrorWithStatusAndMessage(ctx context.Context, w http.ResponseWriter, status int, message string, details string) { - reqID := GetRequestID(ctx) - w.WriteHeader(status) - - var detailValues url.Values - if details != "" { - aulogging.Debugf(ctx, "Request was not successful: [error]: %s", details) - detailValues = url.Values{"details": []string{details}} - } - - apiErr := NewAPIError(reqID, message, detailValues) - EncodeToJSON(ctx, w, apiErr) -} - -// EncodeWithStatus will attempt to encode the provided `value` into the -// response writer `w` and will write the status header. -// If the encoding fails, the http status will not be written to the response writer -// and the function will return an error instead. -func EncodeWithStatus[T any](status int, value *T, w http.ResponseWriter) error { - err := json.NewEncoder(w).Encode(value) - if err != nil { - return errors.Wrap(err, "could not encode type into response buffer") - } - - w.WriteHeader(status) - - return nil -} - -// SendUnauthorizedResponse sends a standardized StatusUnauthorized response to the client. -func SendUnauthorizedResponse(ctx context.Context, w http.ResponseWriter, details string) { - SendErrorWithStatusAndMessage(ctx, w, http.StatusUnauthorized, AuthUnauthorizedMessage, details) -} diff --git a/internal/web/common/error.go b/internal/web/common/error.go deleted file mode 100644 index 6ab6279..0000000 --- a/internal/web/common/error.go +++ /dev/null @@ -1,65 +0,0 @@ -package common - -import ( - "net/url" - "time" -) - -const ( - AuthUnauthorizedMessage string = "auth.unauthorized" // token missing completely or invalid or expired - AuthForbiddenMessage string = "auth.forbidden" // permissions missing - RequestParseErrorMessage string = "request.parse.failed" // Request could not be parsed properly - InternalErrorMessage string = "http.error.internal" // Internal error - UnknownErrorMessage string = "http.error.unknown" // Unknown error - - DownstreamAttSrv string = "attendee.validation.error" - NoSuchAttendee string = "attendee.notfound" - NotAttending string = "attendee.status.not.attending" - - GroupIDInvalidMessage string = "group.id.invalid" - GroupDataInvalidMessage string = "group.data.invalid" - GroupIDNotFoundMessage string = "group.id.notfound" - - GroupMemberNotFound string = "group.member.notfound" -) - -// ServiceError contains information -// which is required to let the application know which status code we want to send -// type ServiceError struct { -// Status int -// } - -type serviceError struct { - errorMessage string -} - -// ErrorFromMessage will construct a new error that can hold -// a predefined error message. -func ErrorFromMessage(message string) error { - return &serviceError{message} -} - -// Error implements the `error` interface. -func (s *serviceError) Error() string { - return string(s.errorMessage) -} - -// APIError is the generic return type for any Failure -// during endpoint operations. -type APIError struct { - RequestID string `json:"requestid"` - Message string `json:"message"` - Timestamp string `json:"timestamp"` - Details url.Values `json:"details"` -} - -// NewAPIError creates a new instance of the `APIError` which will be returned -// to the client if an operation fails. -func NewAPIError(reqID string, message string, details url.Values) *APIError { - return &APIError{ - RequestID: reqID, - Message: message, - Timestamp: time.Now().Format(time.RFC3339), - Details: details, - } -} diff --git a/internal/web/v1/countdown/countdown.go b/internal/web/v1/countdown/countdown.go deleted file mode 100644 index fd066f2..0000000 --- a/internal/web/v1/countdown/countdown.go +++ /dev/null @@ -1,4 +0,0 @@ -package countdown - -// Handler implements methods, which satisfy the endpoint format. -type Handler struct{} diff --git a/internal/web/v1/groups/old_groups_get_test.go b/internal/web/v1/groups/old_groups_get_test.go deleted file mode 100644 index 02fe5e0..0000000 --- a/internal/web/v1/groups/old_groups_get_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package groups - -// import ( -// "context" -// "net/http" -// "net/http/httptest" -// "strings" -// "testing" - -// "github.com/go-chi/chi/v5" -// "github.com/stretchr/testify/require" -// ) - -// // ======================= ListGroups ========================== - -// func TestListGroupsRequest(t *testing.T) { -// tests := []struct { -// name string -// inputURL string -// expected *ListGroupsRequest -// }{ - -// { -// name: "Should successfully create ListGroupRequest", -// inputURL: "http://test.groups.list/groups?member_ids=1,2,3,4,5&min_size=3&max_size=10", -// expected: &ListGroupsRequest{ -// MemberIDs: []string{"1", "2", "3", "4", "5"}, -// MinSize: 3, -// MaxSize: 10, -// }, -// }, -// { -// name: "Should return error when member ID is non numeric", -// inputURL: "http://test.groups.list/groups?member_ids=1,2,E&min_size=3", -// expected: nil, -// }, -// { -// name: "Should return error on negative min size", -// inputURL: "http://test.groups.list/groups?member_ids=1,2,3&min_size=-10", -// expected: nil, -// }, -// { -// name: "Should return error on negative max size", -// inputURL: "http://test.groups.list/groups?member_ids=1,2,3&&max_size=-10", -// expected: nil, -// }, -// } - -// for _, tt := range tests { -// tt := tt -// t.Run(tt.name, func(t *testing.T) { -// h := &Controller{} -// r, err := http.NewRequest(http.MethodGet, tt.inputURL, nil) -// require.NoError(t, err) -// req, err := h.ListGroupsRequest(r, httptest.NewRecorder()) - -// if tt.expected != nil { -// require.NoError(t, err) -// require.Equal(t, tt.expected, req) -// } else { -// require.Nil(t, req) -// require.Error(t, err) -// } -// }) -// } -// } - -// // ============================================================= - -// // ======================= FindGroupsByID ====================== - -// func TestFindGroupsByIDRequest(t *testing.T) { - -// tests := []struct { -// name string -// inputURL string -// expected *FindGroupByIDRequest -// }{ -// { -// name: "Should successfully parse uuid and return request", -// inputURL: "http://test.groups.list/groups/2868913d-4bef-477f-bf00-d2ee246caa3b", -// expected: &FindGroupByIDRequest{ -// GroupID: "2868913d-4bef-477f-bf00-d2ee246caa3b", -// }, -// }, -// { -// name: "Should fail if uuid is not valid", -// inputURL: "http://test.groups.list/groups/invalid", -// expected: nil, -// }, -// } - -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// uuid := tt.inputURL[strings.LastIndex(tt.inputURL, "/")+1:] - -// rctx := chi.NewRouteContext() -// rctx.URLParams.Add("uuid", uuid) - -// ctx := context.WithValue(context.Background(), chi.RouteCtxKey, rctx) -// r, err := http.NewRequestWithContext(ctx, http.MethodGet, tt.inputURL, nil) -// require.NoError(t, err) - -// res, err := (&Controller{}).FindGroupByIDRequest(r, httptest.NewRecorder()) -// if tt.expected == nil { -// require.Error(t, err) -// require.Nil(t, res) -// } else { -// require.NoError(t, err) -// require.Equal(t, tt.expected, res) -// } -// }) -// } -// } - -// // ============================================================= diff --git a/internal/web/v1/groups/old_groups_test.go b/internal/web/v1/groups/old_groups_test.go deleted file mode 100644 index 9fb79c0..0000000 --- a/internal/web/v1/groups/old_groups_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package groups - -// -// import ( -// "context" -// "testing" -// -// "github.com/stretchr/testify/require" -// "github.com/stretchr/testify/suite" -// -// "github.com/eurofurence/reg-room-service/internal/repository/database/inmemorydb" -// groupservice "github.com/eurofurence/reg-room-service/internal/service/groups" -// "github.com/eurofurence/reg-room-service/internal/web/v1/util" -//) -// -//type groupsSuite struct { -// suite.Suite -// *require.Assertions -// -// ctx context.Context -// svc *Controller -//} -// -//func TestGroupsSuite(t *testing.T) { -// g := new(groupsSuite) -// suite.Run(t, g) -//} -// -//func (g *groupsSuite) SetupTest() { -// g.Assertions = require.New(g.T()) -// g.ctx = context.Background() -// g.svc = &Controller{groupservice.NewService(inmemorydb.New())} -//} -// -//func TestParseGroupMemberIDs(t *testing.T) { -// tests := []struct { -// name string -// input string -// expected []string -// expectedErr bool -// }{ -// { -// name: "Test with valid input", -// input: "1,2,3,4,5,6,7", -// expected: []string{"1", "2", "3", "4", "5", "6", "7"}, -// expectedErr: false, -// }, -// { -// name: "Test with invalid input", -// input: "1,2,3,4,5,6,7,abc123", -// expected: nil, -// expectedErr: true, -// }, -// { -// name: "Test with brackets", -// input: "[{1,2,3,4,5,6,7]}]]", -// expected: nil, -// expectedErr: true, -// }, -// } -// -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// memberIDs, err := util.ParseMemberIDs(tc.input) -// require.Equal(t, tc.expected, memberIDs) -// if tc.expectedErr { -// require.Error(t, err) -// } else { -// require.NoError(t, err) -// } -// }) -// } -//} diff --git a/internal/web/v1/groups/utils.go b/internal/web/v1/groups/utils.go deleted file mode 100644 index d1636e3..0000000 --- a/internal/web/v1/groups/utils.go +++ /dev/null @@ -1,23 +0,0 @@ -package groups - -import ( - "context" - "fmt" - apierrors "github.com/eurofurence/reg-room-service/internal/errors" - "github.com/eurofurence/reg-room-service/internal/web/common" - "github.com/google/uuid" - "net/http" -) - -func validateGroupID(ctx context.Context, w http.ResponseWriter, groupID string) error { - if err := uuid.Validate(groupID); err != nil { - common.SendHTTPStatusErrorResponse( - ctx, - w, - apierrors.NewBadRequest(common.GroupIDInvalidMessage, fmt.Sprintf("%q is not a vailid UUID", groupID))) - - return err - } - - return nil -} diff --git a/internal/web/v1/health/health.go b/internal/web/v1/health/health.go deleted file mode 100644 index f82673c..0000000 --- a/internal/web/v1/health/health.go +++ /dev/null @@ -1,4 +0,0 @@ -package health - -// Handler implements methods, which satisfy the endpoint format. -type Handler struct{} diff --git a/internal/web/v1/rooms/rooms.go b/internal/web/v1/rooms/rooms.go deleted file mode 100644 index 87e26a5..0000000 --- a/internal/web/v1/rooms/rooms.go +++ /dev/null @@ -1,8 +0,0 @@ -package rooms - -import "github.com/eurofurence/reg-room-service/internal/controller" - -// Handler implements methods, which satisfy the endpoint format. -type Handler struct { - ctrl controller.Controller -} diff --git a/test/acceptance/groups_create_test.go b/test/acceptance/groups_create_test.go index 12199f4..75cfb2d 100644 --- a/test/acceptance/groups_create_test.go +++ b/test/acceptance/groups_create_test.go @@ -3,6 +3,7 @@ package acceptance import ( "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" "net/http" + "net/url" "testing" "github.com/stretchr/testify/require" @@ -150,7 +151,7 @@ func TestGroupsCreate_UserNoReg(t *testing.T) { response := tstPerformPost("/api/rest/v1/groups", tstRenderJson(groupSent), token) docs.Then("Then the request fails with the expected error") - tstRequireErrorResponse(t, response, http.StatusNotFound, "attendee.notfound", "you do not have a valid registration") + tstRequireErrorResponse(t, response, http.StatusForbidden, "attendee.notfound", "you do not have a valid registration") } func TestGroupsCreate_UserNonAttendingReg(t *testing.T) { @@ -188,3 +189,22 @@ func TestGroupsCreate_InvalidJSONSyntax(t *testing.T) { docs.Then("Then the request fails with the expected error") tstRequireErrorResponse(t, response, http.StatusBadRequest, "group.data.invalid", "please check if your provided JSON is valid") } + +func TestGroupsCreate_InvalidData(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with a registration in attending status") + attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved) + token := tstValidUserToken(t, 101) + + docs.When("When they try to create a room group, but supply invalid information") + groupSent := v1.GroupCreate{ + Name: "", + Flags: []string{"invalid"}, + } + response := tstPerformPost("/api/rest/v1/groups", tstRenderJson(groupSent), token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "group.data.invalid", url.Values{"name": []string{"group name cannot be empty"}, "flags": []string{"no such flag 'invalid'"}}) +} diff --git a/test/acceptance/groups_my_test.go b/test/acceptance/groups_my_test.go new file mode 100644 index 0000000..45115ec --- /dev/null +++ b/test/acceptance/groups_my_test.go @@ -0,0 +1,106 @@ +package acceptance + +import ( + modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" + "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/eurofurence/reg-room-service/docs" +) + +// find my group + +func TestGroupsMy_UserSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a registered attendee with an active registration who is in a group") + id1 := setupExistingGroup(t, "kittens", true, "101") + + docs.When("When the user requests their group") + token := tstValidUserToken(t, 101) + response := tstPerformGet("/api/rest/v1/groups/my", token) + + docs.Then("Then the request is successful and the response is as expected") + actual := modelsv1.Group{} + tstRequireSuccessResponse(t, response, http.StatusOK, &actual) + expected := modelsv1.Group{ + ID: id1, + Name: "kittens", + Flags: []string{"public"}, + Comments: p("A nice comment for kittens"), + MaximumSize: p(int32(6)), + Owner: 42, + Members: []modelsv1.Member{ + { + ID: 42, + Nickname: "", + }, + }, + Invites: nil, + } + require.EqualValues(t, expected, actual, "unexpected differences in response body") +} + +func TestGroupsMy_AnonymousDeny(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an unauthenticated user") + token := tstNoToken() + + docs.When("When they request their group") + response := tstPerformGet("/api/rest/v1/groups/my", token) + + docs.Then("Then the request is denied") + tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation") +} + +func TestGroupsMy_UserNoReg(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with NO registration") + token := tstValidUserToken(t, 101) + + docs.When("When they request their group") + response := tstPerformGet("/api/rest/v1/groups/my", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusForbidden, "attendee.notfound", "you do not have a valid registration") +} + +func TestGroupsMy_UserNonAttendingReg(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with a registration in non-attending status") + attMock.SetupRegistered("101", 42, attendeeservice.StatusNew) + token := tstValidUserToken(t, 101) + + docs.When("When they request their group") + response := tstPerformGet("/api/rest/v1/groups/my", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusForbidden, "attendee.status.not.attending", "registration is not in attending status") +} + +func TestGroupsMy_UserNoGroup(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with a registration in attending status") + attMock.SetupRegistered("101", 42, attendeeservice.StatusPartiallyPaid) + token := tstValidUserToken(t, 101) + + docs.Given("Given they are not in any group") + + docs.When("When they request their group") + response := tstPerformGet("/api/rest/v1/groups/my", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusNotFound, "group.member.notfound", "not in a group") +} diff --git a/test/acceptance/setup_test.go b/test/acceptance/setup_test.go index 0e131f4..bacc584 100644 --- a/test/acceptance/setup_test.go +++ b/test/acceptance/setup_test.go @@ -2,15 +2,15 @@ package acceptance import ( "context" + "github.com/eurofurence/reg-room-service/internal/application/server" "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" "net/http/httptest" - "github.com/eurofurence/reg-room-service/internal/config" + "github.com/eurofurence/reg-room-service/internal/repository/config" "github.com/eurofurence/reg-room-service/internal/repository/database" "github.com/eurofurence/reg-room-service/internal/repository/database/historizeddb" "github.com/eurofurence/reg-room-service/internal/repository/database/inmemorydb" "github.com/eurofurence/reg-room-service/internal/repository/downstreams/authservice" - v1 "github.com/eurofurence/reg-room-service/internal/web/v1" ) var ts *httptest.Server @@ -36,7 +36,7 @@ func tstSetup(configfile string) { } func tstSetupHttpTestServer(db database.Repository, attsrv attendeeservice.AttendeeService) { - router := v1.Router(db, attsrv) + router := server.Router(db, attsrv) ts = httptest.NewServer(router) } diff --git a/test/acceptance/startup_acc_test.go b/test/acceptance/startup_acc_test.go index 73e4099..deb24d7 100644 --- a/test/acceptance/startup_acc_test.go +++ b/test/acceptance/startup_acc_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/eurofurence/reg-room-service/docs" - "github.com/eurofurence/reg-room-service/internal/config" + "github.com/eurofurence/reg-room-service/internal/repository/config" ) // ---------------------------------------------------------- diff --git a/test/acceptance/utils_test.go b/test/acceptance/utils_test.go index 01468ee..7fae447 100644 --- a/test/acceptance/utils_test.go +++ b/test/acceptance/utils_test.go @@ -2,6 +2,7 @@ package acceptance import ( "encoding/json" + "github.com/eurofurence/reg-room-service/internal/application/web" "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" "io/ioutil" "log" @@ -15,8 +16,6 @@ import ( "github.com/stretchr/testify/require" modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" - "github.com/eurofurence/reg-room-service/internal/util/media" - "github.com/eurofurence/reg-room-service/internal/web/common" ) func setupExistingGroup(t *testing.T, name string, public bool, subject string, additionalMemberSubjects ...string) string { @@ -141,7 +140,7 @@ func tstPerformPut(relativeUrlWithLeadingSlash string, requestBody string, token log.Fatal(err) } tstAddAuth(request, token) - request.Header.Set(headers.ContentType, media.ContentTypeApplicationJSON) + request.Header.Set(headers.ContentType, web.ContentTypeApplicationJSON) response, err := http.DefaultClient.Do(request) if err != nil { log.Fatal(err) @@ -155,7 +154,7 @@ func tstPerformPost(relativeUrlWithLeadingSlash string, requestBody string, toke log.Fatal(err) } tstAddAuth(request, token) - request.Header.Set(headers.ContentType, media.ContentTypeApplicationJSON) + request.Header.Set(headers.ContentType, web.ContentTypeApplicationJSON) response, err := http.DefaultClient.Do(request) if err != nil { log.Fatal(err) @@ -182,7 +181,7 @@ func tstPerformDelete(relativeUrlWithLeadingSlash string, token string) tstWebRe log.Fatal(err) } tstAddAuth(request, token) - request.Header.Set(headers.ContentType, media.ContentTypeApplicationJSON) + request.Header.Set(headers.ContentType, web.ContentTypeApplicationJSON) response, err := http.DefaultClient.Do(request) if err != nil { log.Fatal(err) @@ -219,7 +218,7 @@ func tstReadGroup(t *testing.T, location string) modelsv1.Group { func tstRequireErrorResponse(t *testing.T, response tstWebResponse, expectedStatus int, expectedMessage string, expectedDetails interface{}) { require.Equal(t, expectedStatus, response.status, "unexpected http response status") - errorDto := common.APIError{} + errorDto := modelsv1.Error{} tstParseJson(response.body, &errorDto) require.Equal(t, expectedMessage, string(errorDto.Message), "unexpected error code") expectedDetailsStr, ok := expectedDetails.(string) diff --git a/test/resources/testconfig_roomgroups.yaml b/test/resources/testconfig_roomgroups.yaml index 4c9ad40..5f5cc5b 100644 --- a/test/resources/testconfig_roomgroups.yaml +++ b/test/resources/testconfig_roomgroups.yaml @@ -2,6 +2,11 @@ server: port: 8081 service: max_group_size: 6 + group_flags: + - public + room_flags: + - handicapped + - final go_live: public: start_iso_datetime: 2020-12-31T23:59:59+01:00