feat: user service
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
// Package shared provides shared request parsing and error normalization used
|
||||
// by the user-service application and transport layers.
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrorCodeInvalidRequest reports malformed or semantically invalid caller
|
||||
// input.
|
||||
ErrorCodeInvalidRequest = "invalid_request"
|
||||
|
||||
// ErrorCodeConflict reports that the requested mutation conflicts with the
|
||||
// current source-of-truth state.
|
||||
ErrorCodeConflict = "conflict"
|
||||
|
||||
// ErrorCodeSubjectNotFound reports that the requested user subject does not
|
||||
// exist.
|
||||
ErrorCodeSubjectNotFound = "subject_not_found"
|
||||
|
||||
// ErrorCodeServiceUnavailable reports that a required dependency is
|
||||
// temporarily unavailable.
|
||||
ErrorCodeServiceUnavailable = "service_unavailable"
|
||||
|
||||
// ErrorCodeInternalError reports that a local invariant failed unexpectedly.
|
||||
ErrorCodeInternalError = "internal_error"
|
||||
)
|
||||
|
||||
var internalErrorStatusCodes = map[string]int{
|
||||
ErrorCodeInvalidRequest: http.StatusBadRequest,
|
||||
ErrorCodeConflict: http.StatusConflict,
|
||||
ErrorCodeSubjectNotFound: http.StatusNotFound,
|
||||
ErrorCodeServiceUnavailable: http.StatusServiceUnavailable,
|
||||
ErrorCodeInternalError: http.StatusInternalServerError,
|
||||
}
|
||||
|
||||
var internalStableMessages = map[string]string{
|
||||
ErrorCodeConflict: "request conflicts with current state",
|
||||
ErrorCodeSubjectNotFound: "subject not found",
|
||||
ErrorCodeServiceUnavailable: "service is unavailable",
|
||||
ErrorCodeInternalError: "internal server error",
|
||||
}
|
||||
|
||||
// InternalErrorProjection stores the transport-ready representation of one
|
||||
// normalized trusted-internal error.
|
||||
type InternalErrorProjection struct {
|
||||
// StatusCode stores the HTTP status returned to the trusted caller.
|
||||
StatusCode int
|
||||
|
||||
// Code stores the stable machine-readable error code written into the JSON
|
||||
// envelope.
|
||||
Code string
|
||||
|
||||
// Message stores the stable or caller-safe message written into the JSON
|
||||
// envelope.
|
||||
Message string
|
||||
}
|
||||
|
||||
// ServiceError stores one normalized application-layer failure.
|
||||
type ServiceError struct {
|
||||
// Code stores the stable machine-readable error code.
|
||||
Code string
|
||||
|
||||
// Message stores the caller-safe error message.
|
||||
Message string
|
||||
|
||||
// Err stores the wrapped underlying cause when one exists.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns the caller-safe message of ServiceError.
|
||||
func (err *ServiceError) Error() string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(err.Message) != "" {
|
||||
return err.Message
|
||||
}
|
||||
if strings.TrimSpace(err.Code) != "" {
|
||||
return err.Code
|
||||
}
|
||||
if err.Err != nil {
|
||||
return err.Err.Error()
|
||||
}
|
||||
|
||||
return ErrorCodeInternalError
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped underlying cause.
|
||||
func (err *ServiceError) Unwrap() error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err.Err
|
||||
}
|
||||
|
||||
// NewServiceError returns one new normalized application-layer error.
|
||||
func NewServiceError(code string, message string, err error) *ServiceError {
|
||||
return &ServiceError{
|
||||
Code: strings.TrimSpace(code),
|
||||
Message: strings.TrimSpace(message),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidRequest returns one normalized invalid-request error.
|
||||
func InvalidRequest(message string) *ServiceError {
|
||||
return NewServiceError(ErrorCodeInvalidRequest, strings.TrimSpace(message), nil)
|
||||
}
|
||||
|
||||
// Conflict returns one normalized conflict error.
|
||||
func Conflict() *ServiceError {
|
||||
return NewServiceError(ErrorCodeConflict, "", nil)
|
||||
}
|
||||
|
||||
// SubjectNotFound returns one normalized subject-not-found error.
|
||||
func SubjectNotFound() *ServiceError {
|
||||
return NewServiceError(ErrorCodeSubjectNotFound, "", nil)
|
||||
}
|
||||
|
||||
// ServiceUnavailable returns one normalized dependency-unavailable error.
|
||||
func ServiceUnavailable(err error) *ServiceError {
|
||||
return NewServiceError(ErrorCodeServiceUnavailable, "", err)
|
||||
}
|
||||
|
||||
// InternalError returns one normalized invariant-failure error.
|
||||
func InternalError(err error) *ServiceError {
|
||||
return NewServiceError(ErrorCodeInternalError, "", err)
|
||||
}
|
||||
|
||||
// CodeOf returns the normalized service error code carried by err when one is
|
||||
// available.
|
||||
func CodeOf(err error) string {
|
||||
serviceErr, ok := errors.AsType[*ServiceError](err)
|
||||
if !ok || serviceErr == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return serviceErr.Code
|
||||
}
|
||||
|
||||
// ProjectInternalError normalizes err to the frozen trusted-internal HTTP
|
||||
// error surface.
|
||||
func ProjectInternalError(err error) InternalErrorProjection {
|
||||
serviceErr, ok := errors.AsType[*ServiceError](err)
|
||||
code := CodeOf(err)
|
||||
if _, exists := internalErrorStatusCodes[code]; !exists {
|
||||
return InternalErrorProjection{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Code: ErrorCodeInternalError,
|
||||
Message: internalStableMessages[ErrorCodeInternalError],
|
||||
}
|
||||
}
|
||||
|
||||
message := ""
|
||||
if ok && serviceErr != nil {
|
||||
message = serviceErr.Message
|
||||
}
|
||||
if stable, exists := internalStableMessages[code]; exists {
|
||||
message = stable
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = internalStableMessages[ErrorCodeInternalError]
|
||||
}
|
||||
|
||||
return InternalErrorProjection{
|
||||
StatusCode: internalErrorStatusCodes[code],
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// NormalizeString trims surrounding Unicode whitespace from value.
|
||||
func NormalizeString(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// ParseEmail trims value and validates it as one exact normalized e-mail
|
||||
// subject used by the auth-facing contract.
|
||||
func ParseEmail(value string) (common.Email, error) {
|
||||
email := common.Email(NormalizeString(value))
|
||||
if err := email.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// ParseUserID trims value and validates it as one stable user identifier.
|
||||
func ParseUserID(value string) (common.UserID, error) {
|
||||
userID := common.UserID(NormalizeString(value))
|
||||
if err := userID.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// ParseRaceName trims value and validates it as one exact stored race name.
|
||||
func ParseRaceName(value string) (common.RaceName, error) {
|
||||
raceName := common.RaceName(NormalizeString(value))
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return raceName, nil
|
||||
}
|
||||
|
||||
// ParseReasonCode trims value and validates it as one machine-readable reason
|
||||
// code.
|
||||
func ParseReasonCode(value string) (common.ReasonCode, error) {
|
||||
reasonCode := common.ReasonCode(NormalizeString(value))
|
||||
if err := reasonCode.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return reasonCode, nil
|
||||
}
|
||||
|
||||
// ParseLanguageTag trims value and validates it against the current Stage 03
|
||||
// boundary and BCP 47 semantics, returning the canonical tag form.
|
||||
func ParseLanguageTag(value string) (common.LanguageTag, error) {
|
||||
languageTag := common.LanguageTag(NormalizeString(value))
|
||||
if err := languageTag.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
parsedTag, err := language.Parse(languageTag.String())
|
||||
if err != nil {
|
||||
return "", InvalidRequest("language tag must be a valid BCP 47 language tag")
|
||||
}
|
||||
|
||||
canonicalTag := common.LanguageTag(parsedTag.String())
|
||||
if err := canonicalTag.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return canonicalTag, nil
|
||||
}
|
||||
|
||||
// ParseTimeZoneName trims value and validates it against the current Stage 03
|
||||
// boundary and IANA time-zone semantics.
|
||||
func ParseTimeZoneName(value string) (common.TimeZoneName, error) {
|
||||
timeZoneName := common.TimeZoneName(NormalizeString(value))
|
||||
if err := timeZoneName.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
if _, err := time.LoadLocation(timeZoneName.String()); err != nil {
|
||||
return "", InvalidRequest("time zone name must be a valid IANA time zone name")
|
||||
}
|
||||
|
||||
return timeZoneName, nil
|
||||
}
|
||||
|
||||
// ParseRegistrationPreferredLanguage trims value, validates it as one create-
|
||||
// only BCP 47 registration language tag, and returns the canonical tag form.
|
||||
func ParseRegistrationPreferredLanguage(value string) (common.LanguageTag, error) {
|
||||
languageTag, err := ParseLanguageTag(value)
|
||||
if err != nil {
|
||||
return "", reframeFieldError("registration_context.preferred_language", "language tag", err)
|
||||
}
|
||||
|
||||
return languageTag, nil
|
||||
}
|
||||
|
||||
// ParseRegistrationTimeZoneName trims value and validates it as one create-
|
||||
// only IANA registration time-zone name.
|
||||
func ParseRegistrationTimeZoneName(value string) (common.TimeZoneName, error) {
|
||||
timeZoneName, err := ParseTimeZoneName(value)
|
||||
if err != nil {
|
||||
return "", reframeFieldError("registration_context.time_zone", "time zone name", err)
|
||||
}
|
||||
|
||||
return timeZoneName, nil
|
||||
}
|
||||
|
||||
func reframeFieldError(fieldName string, valueName string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
prefix := valueName + " "
|
||||
if strings.HasPrefix(message, prefix) {
|
||||
message = fieldName + " " + strings.TrimPrefix(message, prefix)
|
||||
} else {
|
||||
message = fmt.Sprintf("%s: %s", fieldName, message)
|
||||
}
|
||||
|
||||
return InvalidRequest(message)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseLanguageTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErrCode string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "canonicalizes valid tag",
|
||||
input: " en-us ",
|
||||
want: "en-US",
|
||||
},
|
||||
{
|
||||
name: "rejects invalid tag",
|
||||
input: "en-@",
|
||||
wantErrCode: ErrorCodeInvalidRequest,
|
||||
wantErr: "language tag must be a valid BCP 47 language tag",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseLanguageTag(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Empty(t, got)
|
||||
require.Equal(t, tt.wantErrCode, CodeOf(err))
|
||||
require.Equal(t, tt.wantErr, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, got.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeZoneName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErrCode string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "accepts valid zone",
|
||||
input: " Europe/Kaliningrad ",
|
||||
want: "Europe/Kaliningrad",
|
||||
},
|
||||
{
|
||||
name: "rejects invalid zone",
|
||||
input: "Mars/Olympus",
|
||||
wantErrCode: ErrorCodeInvalidRequest,
|
||||
wantErr: "time zone name must be a valid IANA time zone name",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseTimeZoneName(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Empty(t, got)
|
||||
require.Equal(t, tt.wantErrCode, CodeOf(err))
|
||||
require.Equal(t, tt.wantErr, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, got.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRegistrationPreferredLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseRegistrationPreferredLanguage(" en-us ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "en-US", got.String())
|
||||
|
||||
_, err = ParseRegistrationPreferredLanguage("bad@@tag")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, ErrorCodeInvalidRequest, CodeOf(err))
|
||||
require.Equal(t, "registration_context.preferred_language must be a valid BCP 47 language tag", err.Error())
|
||||
}
|
||||
|
||||
func TestParseRegistrationTimeZoneName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseRegistrationTimeZoneName(" Europe/Kaliningrad ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Europe/Kaliningrad", got.String())
|
||||
|
||||
_, err = ParseRegistrationTimeZoneName("Mars/Olympus")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, ErrorCodeInvalidRequest, CodeOf(err))
|
||||
require.Equal(t, "registration_context.time_zone must be a valid IANA time zone name", err.Error())
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"galaxy/user/internal/logging"
|
||||
)
|
||||
|
||||
// LogServiceOutcome writes one structured service-level outcome log with a
|
||||
// stable severity derived from err and with trace fields attached when ctx
|
||||
// carries an active span.
|
||||
func LogServiceOutcome(logger *slog.Logger, ctx context.Context, message string, err error, attrs ...any) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
attrs = append(attrs, logging.TraceAttrsFromContext(ctx)...)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
logger.InfoContext(ctx, message, attrs...)
|
||||
case isExpectedServiceErrorCode(CodeOf(err)):
|
||||
logger.WarnContext(ctx, message, append(attrs, "error", err.Error())...)
|
||||
default:
|
||||
logger.ErrorContext(ctx, message, append(attrs, "error", err.Error())...)
|
||||
}
|
||||
}
|
||||
|
||||
// MetricOutcome returns the stable low-cardinality outcome label derived from
|
||||
// err for service metrics.
|
||||
func MetricOutcome(err error) string {
|
||||
if err == nil {
|
||||
return "success"
|
||||
}
|
||||
|
||||
code := CodeOf(err)
|
||||
if code == "" {
|
||||
return ErrorCodeInternalError
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// LogEventPublicationFailure writes one structured error log for an auxiliary
|
||||
// post-commit event publication failure.
|
||||
func LogEventPublicationFailure(logger *slog.Logger, ctx context.Context, eventType string, err error, attrs ...any) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
attrs = append(attrs,
|
||||
"event_type", eventType,
|
||||
"error", err.Error(),
|
||||
)
|
||||
attrs = append(attrs, logging.TraceAttrsFromContext(ctx)...)
|
||||
|
||||
logger.ErrorContext(ctx, "auxiliary event publication failed", attrs...)
|
||||
}
|
||||
|
||||
func isExpectedServiceErrorCode(code string) bool {
|
||||
switch code {
|
||||
case ErrorCodeInvalidRequest,
|
||||
ErrorCodeConflict,
|
||||
ErrorCodeSubjectNotFound:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
)
|
||||
|
||||
// BuildRaceNameReservation constructs one validated race-name reservation
|
||||
// record for userID and raceName at reservedAt.
|
||||
func BuildRaceNameReservation(
|
||||
policy ports.RaceNamePolicy,
|
||||
userID common.UserID,
|
||||
raceName common.RaceName,
|
||||
reservedAt time.Time,
|
||||
) (account.RaceNameReservation, error) {
|
||||
if policy == nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: race-name policy must not be nil")
|
||||
}
|
||||
if err := userID.Validate(); err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
|
||||
}
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("build race-name reservation reserved at", reservedAt); err != nil {
|
||||
return account.RaceNameReservation{}, err
|
||||
}
|
||||
|
||||
canonicalKey, err := policy.CanonicalKey(raceName)
|
||||
if err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
|
||||
}
|
||||
|
||||
record := account.RaceNameReservation{
|
||||
CanonicalKey: canonicalKey,
|
||||
UserID: userID,
|
||||
RaceName: raceName,
|
||||
ReservedAt: reservedAt.UTC(),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
Reference in New Issue
Block a user