Add username api
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-12-25 20:55:15 -05:00
parent e5277c05a1
commit 8a216c7e82
20 changed files with 1252 additions and 22 deletions

View File

@@ -22,6 +22,10 @@ tags:
description: Script operations
- name: ScriptPolicy
description: Script policy operations
- name: Thumbnails
description: Thumbnail operations
- name: Users
description: User operations
security:
- cookieAuth: []
paths:
@@ -1634,6 +1638,48 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
/usernames:
post:
summary: Batch fetch usernames
operationId: batchUsernames
tags:
- Users
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- userIds
properties:
userIds:
type: array
items:
type: integer
format: uint64
maxItems: 100
description: Array of user IDs (max 100)
responses:
"200":
description: Successful response
content:
application/json:
schema:
type: object
properties:
usernames:
type: object
additionalProperties:
type: string
description: Map of user ID to username
default:
description: General Error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
components:
securitySchemes:
cookieAuth:

View File

@@ -187,6 +187,12 @@ type Invoker interface {
//
// POST /thumbnails/users
BatchUserThumbnails(ctx context.Context, request *BatchUserThumbnailsReq) (*BatchUserThumbnailsOK, error)
// BatchUsernames invokes batchUsernames operation.
//
// Batch fetch usernames.
//
// POST /usernames
BatchUsernames(ctx context.Context, request *BatchUsernamesReq) (*BatchUsernamesOK, error)
// CreateMapfix invokes createMapfix operation.
//
// Trigger the validator to create a mapfix.
@@ -3609,6 +3615,82 @@ func (c *Client) sendBatchUserThumbnails(ctx context.Context, request *BatchUser
return result, nil
}
// BatchUsernames invokes batchUsernames operation.
//
// Batch fetch usernames.
//
// POST /usernames
func (c *Client) BatchUsernames(ctx context.Context, request *BatchUsernamesReq) (*BatchUsernamesOK, error) {
res, err := c.sendBatchUsernames(ctx, request)
return res, err
}
func (c *Client) sendBatchUsernames(ctx context.Context, request *BatchUsernamesReq) (res *BatchUsernamesOK, err error) {
otelAttrs := []attribute.KeyValue{
otelogen.OperationID("batchUsernames"),
semconv.HTTPRequestMethodKey.String("POST"),
semconv.URLTemplateKey.String("/usernames"),
}
otelAttrs = append(otelAttrs, c.cfg.Attributes...)
// Run stopwatch.
startTime := time.Now()
defer func() {
// Use floating point division here for higher precision (instead of Millisecond method).
elapsedDuration := time.Since(startTime)
c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...))
}()
// Increment request counter.
c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
// Start a span for this request.
ctx, span := c.cfg.Tracer.Start(ctx, BatchUsernamesOperation,
trace.WithAttributes(otelAttrs...),
clientSpanKind,
)
// Track stage for error reporting.
var stage string
defer func() {
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, stage)
c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
}
span.End()
}()
stage = "BuildURL"
u := uri.Clone(c.requestURL(ctx))
var pathParts [1]string
pathParts[0] = "/usernames"
uri.AddPathParts(u, pathParts[:]...)
stage = "EncodeRequest"
r, err := ht.NewRequest(ctx, "POST", u)
if err != nil {
return res, errors.Wrap(err, "create request")
}
if err := encodeBatchUsernamesRequest(request, r); err != nil {
return res, errors.Wrap(err, "encode request")
}
stage = "SendRequest"
resp, err := c.cfg.Client.Do(r)
if err != nil {
return res, errors.Wrap(err, "do request")
}
defer resp.Body.Close()
stage = "DecodeResponse"
result, err := decodeBatchUsernamesResponse(resp)
if err != nil {
return res, errors.Wrap(err, "decode response")
}
return result, nil
}
// CreateMapfix invokes createMapfix operation.
//
// Trigger the validator to create a mapfix.

View File

@@ -5092,6 +5092,158 @@ func (s *Server) handleBatchUserThumbnailsRequest(args [0]string, argsEscaped bo
}
}
// handleBatchUsernamesRequest handles batchUsernames operation.
//
// Batch fetch usernames.
//
// POST /usernames
func (s *Server) handleBatchUsernamesRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) {
statusWriter := &codeRecorder{ResponseWriter: w}
w = statusWriter
otelAttrs := []attribute.KeyValue{
otelogen.OperationID("batchUsernames"),
semconv.HTTPRequestMethodKey.String("POST"),
semconv.HTTPRouteKey.String("/usernames"),
}
// Start a span for this request.
ctx, span := s.cfg.Tracer.Start(r.Context(), BatchUsernamesOperation,
trace.WithAttributes(otelAttrs...),
serverSpanKind,
)
defer span.End()
// Add Labeler to context.
labeler := &Labeler{attrs: otelAttrs}
ctx = contextWithLabeler(ctx, labeler)
// Run stopwatch.
startTime := time.Now()
defer func() {
elapsedDuration := time.Since(startTime)
attrSet := labeler.AttributeSet()
attrs := attrSet.ToSlice()
code := statusWriter.status
if code != 0 {
codeAttr := semconv.HTTPResponseStatusCode(code)
attrs = append(attrs, codeAttr)
span.SetAttributes(codeAttr)
}
attrOpt := metric.WithAttributes(attrs...)
// Increment request counter.
s.requests.Add(ctx, 1, attrOpt)
// Use floating point division here for higher precision (instead of Millisecond method).
s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt)
}()
var (
recordError = func(stage string, err error) {
span.RecordError(err)
// https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status
// Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges,
// unless there was another error (e.g., network error receiving the response body; or 3xx codes with
// max redirects exceeded), in which case status MUST be set to Error.
code := statusWriter.status
if code < 100 || code >= 500 {
span.SetStatus(codes.Error, stage)
}
attrSet := labeler.AttributeSet()
attrs := attrSet.ToSlice()
if code != 0 {
attrs = append(attrs, semconv.HTTPResponseStatusCode(code))
}
s.errors.Add(ctx, 1, metric.WithAttributes(attrs...))
}
err error
opErrContext = ogenerrors.OperationContext{
Name: BatchUsernamesOperation,
ID: "batchUsernames",
}
)
var rawBody []byte
request, rawBody, close, err := s.decodeBatchUsernamesRequest(r)
if err != nil {
err = &ogenerrors.DecodeRequestError{
OperationContext: opErrContext,
Err: err,
}
defer recordError("DecodeRequest", err)
s.cfg.ErrorHandler(ctx, w, r, err)
return
}
defer func() {
if err := close(); err != nil {
recordError("CloseRequest", err)
}
}()
var response *BatchUsernamesOK
if m := s.cfg.Middleware; m != nil {
mreq := middleware.Request{
Context: ctx,
OperationName: BatchUsernamesOperation,
OperationSummary: "Batch fetch usernames",
OperationID: "batchUsernames",
Body: request,
RawBody: rawBody,
Params: middleware.Parameters{},
Raw: r,
}
type (
Request = *BatchUsernamesReq
Params = struct{}
Response = *BatchUsernamesOK
)
response, err = middleware.HookMiddleware[
Request,
Params,
Response,
](
m,
mreq,
nil,
func(ctx context.Context, request Request, params Params) (response Response, err error) {
response, err = s.h.BatchUsernames(ctx, request)
return response, err
},
)
} else {
response, err = s.h.BatchUsernames(ctx, request)
}
if err != nil {
if errRes, ok := errors.Into[*ErrorStatusCode](err); ok {
if err := encodeErrorResponse(errRes, w, span); err != nil {
defer recordError("Internal", err)
}
return
}
if errors.Is(err, ht.ErrNotImplemented) {
s.cfg.ErrorHandler(ctx, w, r, err)
return
}
if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil {
defer recordError("Internal", err)
}
return
}
if err := encodeBatchUsernamesResponse(response, w, span); err != nil {
defer recordError("EncodeResponse", err)
if !errors.Is(err, ht.ErrInternalServerErrorResponse) {
s.cfg.ErrorHandler(ctx, w, r, err)
}
return
}
}
// handleCreateMapfixRequest handles createMapfix operation.
//
// Trigger the validator to create a mapfix.

View File

@@ -857,6 +857,233 @@ func (s *BatchUserThumbnailsReqSize) UnmarshalJSON(data []byte) error {
return s.Decode(d)
}
// Encode implements json.Marshaler.
func (s *BatchUsernamesOK) Encode(e *jx.Encoder) {
e.ObjStart()
s.encodeFields(e)
e.ObjEnd()
}
// encodeFields encodes fields.
func (s *BatchUsernamesOK) encodeFields(e *jx.Encoder) {
{
if s.Usernames.Set {
e.FieldStart("usernames")
s.Usernames.Encode(e)
}
}
}
var jsonFieldsNameOfBatchUsernamesOK = [1]string{
0: "usernames",
}
// Decode decodes BatchUsernamesOK from json.
func (s *BatchUsernamesOK) Decode(d *jx.Decoder) error {
if s == nil {
return errors.New("invalid: unable to decode BatchUsernamesOK to nil")
}
if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error {
switch string(k) {
case "usernames":
if err := func() error {
s.Usernames.Reset()
if err := s.Usernames.Decode(d); err != nil {
return err
}
return nil
}(); err != nil {
return errors.Wrap(err, "decode field \"usernames\"")
}
default:
return d.Skip()
}
return nil
}); err != nil {
return errors.Wrap(err, "decode BatchUsernamesOK")
}
return nil
}
// MarshalJSON implements stdjson.Marshaler.
func (s *BatchUsernamesOK) MarshalJSON() ([]byte, error) {
e := jx.Encoder{}
s.Encode(&e)
return e.Bytes(), nil
}
// UnmarshalJSON implements stdjson.Unmarshaler.
func (s *BatchUsernamesOK) UnmarshalJSON(data []byte) error {
d := jx.DecodeBytes(data)
return s.Decode(d)
}
// Encode implements json.Marshaler.
func (s BatchUsernamesOKUsernames) Encode(e *jx.Encoder) {
e.ObjStart()
s.encodeFields(e)
e.ObjEnd()
}
// encodeFields implements json.Marshaler.
func (s BatchUsernamesOKUsernames) encodeFields(e *jx.Encoder) {
for k, elem := range s {
e.FieldStart(k)
e.Str(elem)
}
}
// Decode decodes BatchUsernamesOKUsernames from json.
func (s *BatchUsernamesOKUsernames) Decode(d *jx.Decoder) error {
if s == nil {
return errors.New("invalid: unable to decode BatchUsernamesOKUsernames to nil")
}
m := s.init()
if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error {
var elem string
if err := func() error {
v, err := d.Str()
elem = string(v)
if err != nil {
return err
}
return nil
}(); err != nil {
return errors.Wrapf(err, "decode field %q", k)
}
m[string(k)] = elem
return nil
}); err != nil {
return errors.Wrap(err, "decode BatchUsernamesOKUsernames")
}
return nil
}
// MarshalJSON implements stdjson.Marshaler.
func (s BatchUsernamesOKUsernames) MarshalJSON() ([]byte, error) {
e := jx.Encoder{}
s.Encode(&e)
return e.Bytes(), nil
}
// UnmarshalJSON implements stdjson.Unmarshaler.
func (s *BatchUsernamesOKUsernames) UnmarshalJSON(data []byte) error {
d := jx.DecodeBytes(data)
return s.Decode(d)
}
// Encode implements json.Marshaler.
func (s *BatchUsernamesReq) Encode(e *jx.Encoder) {
e.ObjStart()
s.encodeFields(e)
e.ObjEnd()
}
// encodeFields encodes fields.
func (s *BatchUsernamesReq) encodeFields(e *jx.Encoder) {
{
e.FieldStart("userIds")
e.ArrStart()
for _, elem := range s.UserIds {
e.UInt64(elem)
}
e.ArrEnd()
}
}
var jsonFieldsNameOfBatchUsernamesReq = [1]string{
0: "userIds",
}
// Decode decodes BatchUsernamesReq from json.
func (s *BatchUsernamesReq) Decode(d *jx.Decoder) error {
if s == nil {
return errors.New("invalid: unable to decode BatchUsernamesReq to nil")
}
var requiredBitSet [1]uint8
if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error {
switch string(k) {
case "userIds":
requiredBitSet[0] |= 1 << 0
if err := func() error {
s.UserIds = make([]uint64, 0)
if err := d.Arr(func(d *jx.Decoder) error {
var elem uint64
v, err := d.UInt64()
elem = uint64(v)
if err != nil {
return err
}
s.UserIds = append(s.UserIds, elem)
return nil
}); err != nil {
return err
}
return nil
}(); err != nil {
return errors.Wrap(err, "decode field \"userIds\"")
}
default:
return d.Skip()
}
return nil
}); err != nil {
return errors.Wrap(err, "decode BatchUsernamesReq")
}
// Validate required fields.
var failures []validate.FieldError
for i, mask := range [1]uint8{
0b00000001,
} {
if result := (requiredBitSet[i] & mask) ^ mask; result != 0 {
// Mask only required fields and check equality to mask using XOR.
//
// If XOR result is not zero, result is not equal to expected, so some fields are missed.
// Bits of fields which would be set are actually bits of missed fields.
missed := bits.OnesCount8(result)
for bitN := 0; bitN < missed; bitN++ {
bitIdx := bits.TrailingZeros8(result)
fieldIdx := i*8 + bitIdx
var name string
if fieldIdx < len(jsonFieldsNameOfBatchUsernamesReq) {
name = jsonFieldsNameOfBatchUsernamesReq[fieldIdx]
} else {
name = strconv.Itoa(fieldIdx)
}
failures = append(failures, validate.FieldError{
Name: name,
Error: validate.ErrFieldRequired,
})
// Reset bit.
result &^= 1 << bitIdx
}
}
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
// MarshalJSON implements stdjson.Marshaler.
func (s *BatchUsernamesReq) MarshalJSON() ([]byte, error) {
e := jx.Encoder{}
s.Encode(&e)
return e.Bytes(), nil
}
// UnmarshalJSON implements stdjson.Unmarshaler.
func (s *BatchUsernamesReq) UnmarshalJSON(data []byte) error {
d := jx.DecodeBytes(data)
return s.Decode(d)
}
// Encode implements json.Marshaler.
func (s *Error) Encode(e *jx.Encoder) {
e.ObjStart()
@@ -2253,6 +2480,40 @@ func (s *OptBatchUserThumbnailsReqSize) UnmarshalJSON(data []byte) error {
return s.Decode(d)
}
// Encode encodes BatchUsernamesOKUsernames as json.
func (o OptBatchUsernamesOKUsernames) Encode(e *jx.Encoder) {
if !o.Set {
return
}
o.Value.Encode(e)
}
// Decode decodes BatchUsernamesOKUsernames from json.
func (o *OptBatchUsernamesOKUsernames) Decode(d *jx.Decoder) error {
if o == nil {
return errors.New("invalid: unable to decode OptBatchUsernamesOKUsernames to nil")
}
o.Set = true
o.Value = make(BatchUsernamesOKUsernames)
if err := o.Value.Decode(d); err != nil {
return err
}
return nil
}
// MarshalJSON implements stdjson.Marshaler.
func (s OptBatchUsernamesOKUsernames) MarshalJSON() ([]byte, error) {
e := jx.Encoder{}
s.Encode(&e)
return e.Bytes(), nil
}
// UnmarshalJSON implements stdjson.Unmarshaler.
func (s *OptBatchUsernamesOKUsernames) UnmarshalJSON(data []byte) error {
d := jx.DecodeBytes(data)
return s.Decode(d)
}
// Encode encodes int32 as json.
func (o OptInt32) Encode(e *jx.Encoder) {
if !o.Set {

View File

@@ -32,6 +32,7 @@ const (
ActionSubmissionValidatedOperation OperationName = "ActionSubmissionValidated"
BatchAssetThumbnailsOperation OperationName = "BatchAssetThumbnails"
BatchUserThumbnailsOperation OperationName = "BatchUserThumbnails"
BatchUsernamesOperation OperationName = "BatchUsernames"
CreateMapfixOperation OperationName = "CreateMapfix"
CreateMapfixAuditCommentOperation OperationName = "CreateMapfixAuditComment"
CreateScriptOperation OperationName = "CreateScript"

View File

@@ -173,6 +173,85 @@ func (s *Server) decodeBatchUserThumbnailsRequest(r *http.Request) (
}
}
func (s *Server) decodeBatchUsernamesRequest(r *http.Request) (
req *BatchUsernamesReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request BatchUsernamesReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
if err := func() error {
if err := request.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return req, rawBody, close, errors.Wrap(err, "validate")
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeCreateMapfixRequest(r *http.Request) (
req *MapfixTriggerCreate,
rawBody []byte,

View File

@@ -38,6 +38,20 @@ func encodeBatchUserThumbnailsRequest(
return nil
}
func encodeBatchUsernamesRequest(
req *BatchUsernamesReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeCreateMapfixRequest(
req *MapfixTriggerCreate,
r *http.Request,

View File

@@ -1641,6 +1641,98 @@ func decodeBatchUserThumbnailsResponse(resp *http.Response) (res *BatchUserThumb
return res, errors.Wrap(defRes, "error")
}
func decodeBatchUsernamesResponse(resp *http.Response) (res *BatchUsernamesOK, _ error) {
switch resp.StatusCode {
case 200:
// Code 200.
ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
return res, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
buf, err := io.ReadAll(resp.Body)
if err != nil {
return res, err
}
d := jx.DecodeBytes(buf)
var response BatchUsernamesOK
if err := func() error {
if err := response.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return res, err
}
return &response, nil
default:
return res, validate.InvalidContentType(ct)
}
}
// Convenient error response.
defRes, err := func() (res *ErrorStatusCode, err error) {
ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
return res, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
buf, err := io.ReadAll(resp.Body)
if err != nil {
return res, err
}
d := jx.DecodeBytes(buf)
var response Error
if err := func() error {
if err := response.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return res, err
}
// Validate response.
if err := func() error {
if err := response.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return res, errors.Wrap(err, "validate")
}
return &ErrorStatusCode{
StatusCode: resp.StatusCode,
Response: response,
}, nil
default:
return res, validate.InvalidContentType(ct)
}
}()
if err != nil {
return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode)
}
return res, errors.Wrap(defRes, "error")
}
func decodeCreateMapfixResponse(resp *http.Response) (res *OperationID, _ error) {
switch resp.StatusCode {
case 201:

View File

@@ -211,6 +211,20 @@ func encodeBatchUserThumbnailsResponse(response *BatchUserThumbnailsOK, w http.R
return nil
}
func encodeBatchUsernamesResponse(response *BatchUsernamesOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
span.SetStatus(codes.Ok, http.StatusText(200))
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeCreateMapfixResponse(response *OperationID, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(201)

View File

@@ -1595,6 +1595,26 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
case 'u': // Prefix: "usernames"
if l := len("usernames"); len(elem) >= l && elem[0:l] == "usernames" {
elem = elem[l:]
} else {
break
}
if len(elem) == 0 {
// Leaf node.
switch r.Method {
case "POST":
s.handleBatchUsernamesRequest([0]string{}, elemIsEscaped, w, r)
default:
s.notAllowed(w, r, "POST")
}
return
}
}
}
@@ -3465,6 +3485,31 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) {
}
case 'u': // Prefix: "usernames"
if l := len("usernames"); len(elem) >= l && elem[0:l] == "usernames" {
elem = elem[l:]
} else {
break
}
if len(elem) == 0 {
// Leaf node.
switch method {
case "POST":
r.name = BatchUsernamesOperation
r.summary = "Batch fetch usernames"
r.operationID = "batchUsernames"
r.operationGroup = ""
r.pathPattern = "/usernames"
r.args = args
r.count = 0
return r, true
default:
return
}
}
}
}

View File

@@ -399,6 +399,48 @@ func (s *BatchUserThumbnailsReqSize) UnmarshalText(data []byte) error {
}
}
type BatchUsernamesOK struct {
// Map of user ID to username.
Usernames OptBatchUsernamesOKUsernames `json:"usernames"`
}
// GetUsernames returns the value of Usernames.
func (s *BatchUsernamesOK) GetUsernames() OptBatchUsernamesOKUsernames {
return s.Usernames
}
// SetUsernames sets the value of Usernames.
func (s *BatchUsernamesOK) SetUsernames(val OptBatchUsernamesOKUsernames) {
s.Usernames = val
}
// Map of user ID to username.
type BatchUsernamesOKUsernames map[string]string
func (s *BatchUsernamesOKUsernames) init() BatchUsernamesOKUsernames {
m := *s
if m == nil {
m = map[string]string{}
*s = m
}
return m
}
type BatchUsernamesReq struct {
// Array of user IDs (max 100).
UserIds []uint64 `json:"userIds"`
}
// GetUserIds returns the value of UserIds.
func (s *BatchUsernamesReq) GetUserIds() []uint64 {
return s.UserIds
}
// SetUserIds sets the value of UserIds.
func (s *BatchUsernamesReq) SetUserIds(val []uint64) {
s.UserIds = val
}
type CookieAuth struct {
APIKey string
Roles []string
@@ -1294,6 +1336,52 @@ func (o OptBatchUserThumbnailsReqSize) Or(d BatchUserThumbnailsReqSize) BatchUse
return d
}
// NewOptBatchUsernamesOKUsernames returns new OptBatchUsernamesOKUsernames with value set to v.
func NewOptBatchUsernamesOKUsernames(v BatchUsernamesOKUsernames) OptBatchUsernamesOKUsernames {
return OptBatchUsernamesOKUsernames{
Value: v,
Set: true,
}
}
// OptBatchUsernamesOKUsernames is optional BatchUsernamesOKUsernames.
type OptBatchUsernamesOKUsernames struct {
Value BatchUsernamesOKUsernames
Set bool
}
// IsSet returns true if OptBatchUsernamesOKUsernames was set.
func (o OptBatchUsernamesOKUsernames) IsSet() bool { return o.Set }
// Reset unsets value.
func (o *OptBatchUsernamesOKUsernames) Reset() {
var v BatchUsernamesOKUsernames
o.Value = v
o.Set = false
}
// SetTo sets value to v.
func (o *OptBatchUsernamesOKUsernames) SetTo(v BatchUsernamesOKUsernames) {
o.Set = true
o.Value = v
}
// Get returns value and boolean that denotes whether value was set.
func (o OptBatchUsernamesOKUsernames) Get() (v BatchUsernamesOKUsernames, ok bool) {
if !o.Set {
return v, false
}
return o.Value, true
}
// Or returns value if set, or given parameter if does not.
func (o OptBatchUsernamesOKUsernames) Or(d BatchUsernamesOKUsernames) BatchUsernamesOKUsernames {
if v, ok := o.Get(); ok {
return v
}
return d
}
// NewOptGetAssetThumbnailSize returns new OptGetAssetThumbnailSize with value set to v.
func NewOptGetAssetThumbnailSize(v GetAssetThumbnailSize) OptGetAssetThumbnailSize {
return OptGetAssetThumbnailSize{

View File

@@ -167,6 +167,12 @@ type Handler interface {
//
// POST /thumbnails/users
BatchUserThumbnails(ctx context.Context, req *BatchUserThumbnailsReq) (*BatchUserThumbnailsOK, error)
// BatchUsernames implements batchUsernames operation.
//
// Batch fetch usernames.
//
// POST /usernames
BatchUsernames(ctx context.Context, req *BatchUsernamesReq) (*BatchUsernamesOK, error)
// CreateMapfix implements createMapfix operation.
//
// Trigger the validator to create a mapfix.

View File

@@ -250,6 +250,15 @@ func (UnimplementedHandler) BatchUserThumbnails(ctx context.Context, req *BatchU
return r, ht.ErrNotImplemented
}
// BatchUsernames implements batchUsernames operation.
//
// Batch fetch usernames.
//
// POST /usernames
func (UnimplementedHandler) BatchUsernames(ctx context.Context, req *BatchUsernamesReq) (r *BatchUsernamesOK, _ error) {
return r, ht.ErrNotImplemented
}
// CreateMapfix implements createMapfix operation.
//
// Trigger the validator to create a mapfix.

View File

@@ -168,6 +168,37 @@ func (s BatchUserThumbnailsReqSize) Validate() error {
}
}
func (s *BatchUsernamesReq) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
if s.UserIds == nil {
return errors.New("nil is invalid value")
}
if err := (validate.Array{
MinLength: 0,
MinLengthSet: false,
MaxLength: 100,
MaxLengthSet: true,
}).ValidateLength(len(s.UserIds)); err != nil {
return errors.Wrap(err, "array")
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "userIds",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
func (s *Error) Validate() error {
if s == nil {
return validate.ErrNilPointer

72
pkg/roblox/users.go Normal file
View File

@@ -0,0 +1,72 @@
package roblox
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
// UserData represents a single user's information
type UserData struct {
ID uint64 `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
}
// BatchUsersResponse represents the API response for batch user requests
type BatchUsersResponse struct {
Data []UserData `json:"data"`
}
// GetUsernames fetches usernames for multiple users in a single batch request
// Roblox allows up to 100 users per batch
func (c *Client) GetUsernames(userIDs []uint64) ([]UserData, error) {
if len(userIDs) == 0 {
return []UserData{}, nil
}
if len(userIDs) > 100 {
return nil, GetError("batch size cannot exceed 100 users")
}
// Build request payload
payload := map[string][]uint64{
"userIds": userIDs,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, GetError("JSONMarshalError: " + err.Error())
}
req, err := http.NewRequest("POST", "https://users.roblox.com/v1/users", bytes.NewBuffer(jsonData))
if err != nil {
return nil, GetError("RequestCreationError: " + err.Error())
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HttpClient.Do(req)
if err != nil {
return nil, GetError("RequestError: " + err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, GetError(fmt.Sprintf("ResponseError: status code %d, body: %s", resp.StatusCode, string(body)))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, GetError("ReadBodyError: " + err.Error())
}
var response BatchUsersResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, GetError("JSONUnmarshalError: " + err.Error())
}
return response.Data, nil
}

View File

@@ -55,3 +55,13 @@ func (s *Service) GetSingleAssetThumbnail(ctx context.Context, assetID uint64, s
func (s *Service) GetSingleUserAvatarThumbnail(ctx context.Context, userID uint64, size roblox.ThumbnailSize) (string, error) {
return s.thumbnailService.GetSingleUserAvatarThumbnail(ctx, userID, size)
}
// GetUsernames proxies to the thumbnail service
func (s *Service) GetUsernames(ctx context.Context, userIDs []uint64) (map[uint64]string, error) {
return s.thumbnailService.GetUsernames(ctx, userIDs)
}
// GetSingleUsername proxies to the thumbnail service
func (s *Service) GetSingleUsername(ctx context.Context, userID uint64) (string, error) {
return s.thumbnailService.GetSingleUsername(ctx, userID)
}

108
pkg/service/users.go Normal file
View File

@@ -0,0 +1,108 @@
package service
import (
"context"
"encoding/json"
"fmt"
"time"
"git.itzana.me/strafesnet/maps-service/pkg/roblox"
"github.com/redis/go-redis/v9"
)
// CachedUser represents a cached user entry
type CachedUser struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
CachedAt time.Time `json:"cachedAt"`
}
// GetUsernames fetches usernames with Redis caching and batching
func (s *ThumbnailService) GetUsernames(ctx context.Context, userIDs []uint64) (map[uint64]string, error) {
if len(userIDs) == 0 {
return map[uint64]string{}, nil
}
result := make(map[uint64]string)
var missingIDs []uint64
// Try to get from cache first
for _, userID := range userIDs {
cacheKey := fmt.Sprintf("user:name:%d", userID)
cached, err := s.redisClient.Get(ctx, cacheKey).Result()
if err == redis.Nil {
// Cache miss
missingIDs = append(missingIDs, userID)
} else if err != nil {
// Redis error - treat as cache miss
missingIDs = append(missingIDs, userID)
} else {
// Cache hit
var user CachedUser
if err := json.Unmarshal([]byte(cached), &user); err == nil && user.Name != "" {
result[userID] = user.Name
} else {
missingIDs = append(missingIDs, userID)
}
}
}
// If all were cached, return early
if len(missingIDs) == 0 {
return result, nil
}
// Batch fetch missing usernames from Roblox API
// Split into batches of 100 (Roblox API limit)
for i := 0; i < len(missingIDs); i += 100 {
end := i + 100
if end > len(missingIDs) {
end = len(missingIDs)
}
batch := missingIDs[i:end]
var users []roblox.UserData
var err error
users, err = s.robloxClient.GetUsernames(batch)
if err != nil {
return nil, fmt.Errorf("failed to fetch usernames: %w", err)
}
// Process results and cache them
for _, user := range users {
cached := CachedUser{
Name: user.Name,
DisplayName: user.DisplayName,
CachedAt: time.Now(),
}
if user.Name != "" {
result[user.ID] = user.Name
}
// Cache the result
cacheKey := fmt.Sprintf("user:name:%d", user.ID)
cachedJSON, _ := json.Marshal(cached)
// Cache usernames for a long time (7 days) since they rarely change
s.redisClient.Set(ctx, cacheKey, cachedJSON, 7*24*time.Hour)
}
}
return result, nil
}
// GetSingleUsername is a convenience method for fetching a single username
func (s *ThumbnailService) GetSingleUsername(ctx context.Context, userID uint64) (string, error) {
results, err := s.GetUsernames(ctx, []uint64{userID})
if err != nil {
return "", err
}
if name, ok := results[userID]; ok {
return name, nil
}
return "", fmt.Errorf("username not available for user %d", userID)
}

33
pkg/web_api/users.go Normal file
View File

@@ -0,0 +1,33 @@
package web_api
import (
"context"
"strconv"
"git.itzana.me/strafesnet/maps-service/pkg/api"
)
// BatchUsernames handles batch fetching of usernames
func (svc *Service) BatchUsernames(ctx context.Context, req *api.BatchUsernamesReq) (*api.BatchUsernamesOK, error) {
if len(req.UserIds) == 0 {
return &api.BatchUsernamesOK{
Usernames: api.NewOptBatchUsernamesOKUsernames(map[string]string{}),
}, nil
}
// Fetch usernames from service
usernames, err := svc.inner.GetUsernames(ctx, req.UserIds)
if err != nil {
return nil, err
}
// Convert map[uint64]string to map[string]string for JSON
result := make(map[string]string, len(usernames))
for userID, username := range usernames {
result[strconv.FormatUint(userID, 10)] = username
}
return &api.BatchUsernamesOK{
Usernames: api.NewOptBatchUsernamesOKUsernames(result),
}, nil
}

View File

@@ -3,38 +3,22 @@ import { StatusChip } from "@/app/_components/statusChip";
import { SubmissionStatus } from "@/app/ts/Submission";
import { MapfixStatus } from "@/app/ts/Mapfix";
import {Status, StatusMatches} from "@/app/ts/Status";
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import LaunchIcon from '@mui/icons-material/Launch';
import { useUserThumbnail } from "@/app/hooks/useThumbnails";
import { useUsername } from "@/app/hooks/useUsername";
function SubmitterName({ submitterId }: { submitterId: number }) {
const [name, setName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const { username, isLoading } = useUsername(submitterId);
useEffect(() => {
if (!submitterId) return;
const fetchUserName = async () => {
try {
setLoading(true);
const response = await fetch(`/proxy/users/${submitterId}`);
if (!response.ok) throw new Error('Failed to fetch user');
const data = await response.json();
setName(`@${data.name}`);
} catch {
setName(String(submitterId));
} finally {
setLoading(false);
}
};
fetchUserName();
}, [submitterId]);
if (isLoading) return <Typography variant="body1">Loading...</Typography>;
const displayName = username ? `@${username}` : String(submitterId);
if (loading) return <Typography variant="body1">Loading...</Typography>;
return <a href={`https://www.roblox.com/users/${submitterId}/profile`} target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none', color: 'inherit' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, '&:hover': { textDecoration: 'underline' } }}>
<Typography>
{name || submitterId}
{displayName}
</Typography>
<LaunchIcon sx={{ fontSize: '1rem', color: 'text.secondary' }} />
</Box>

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react';
interface UserBatchResponse {
usernames: Record<string, string>;
}
// Batching queue
class UserBatcher {
private queue: Map<number, Set<(name: string | null) => void>> = new Map();
private timeoutId: NodeJS.Timeout | null = null;
private batchDelay = 50; // 50ms delay to collect requests
async fetchBatch(ids: number[]): Promise<Record<number, string>> {
try {
const response = await fetch('/v1/usernames', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userIds: ids }),
});
if (!response.ok) {
throw new Error(`Failed to fetch usernames: ${response.statusText}`);
}
const data: UserBatchResponse = await response.json();
// Convert string keys back to numbers
const result: Record<number, string> = {};
Object.entries(data.usernames || {}).forEach(([key, value]) => {
result[parseInt(key, 10)] = value;
});
return result;
} catch (error) {
console.error('Error fetching usernames:', error);
return {};
}
}
queueRequest(id: number, callback: (name: string | null) => void) {
if (!this.queue.has(id)) {
this.queue.set(id, new Set());
}
this.queue.get(id)!.add(callback);
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
this.timeoutId = setTimeout(() => {
this.flushQueue();
}, this.batchDelay);
}
private async flushQueue() {
if (this.queue.size === 0) return;
const ids = Array.from(this.queue.keys());
const callbacks = new Map(this.queue);
this.queue.clear();
// Split into batches of 100 (API limit)
for (let i = 0; i < ids.length; i += 100) {
const batchIds = ids.slice(i, i + 100);
const results = await this.fetchBatch(batchIds);
batchIds.forEach((id) => {
const name = results[id] || null;
const cbs = callbacks.get(id);
if (cbs) {
cbs.forEach((cb) => cb(name));
}
});
}
}
}
// Singleton batcher instance
const batcher = new UserBatcher();
/**
* Hook to fetch a single username with automatic batching
*/
export function useUsername(userId: number | undefined) {
const [username, setUsername] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!userId) {
setUsername(null);
setIsLoading(false);
return;
}
setIsLoading(true);
batcher.queueRequest(userId, (name) => {
setUsername(name);
setIsLoading(false);
});
}, [userId]);
return { username, isLoading };
}