This commit is contained in:
46
openapi.yaml
46
openapi.yaml
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
72
pkg/roblox/users.go
Normal 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
|
||||
}
|
||||
@@ -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
108
pkg/service/users.go
Normal 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
33
pkg/web_api/users.go
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
103
web/src/app/hooks/useUsername.ts
Normal file
103
web/src/app/hooks/useUsername.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user