diff --git a/scnserver/README.md b/scnserver/README.md index 2f8cccf..1500a8e 100644 --- a/scnserver/README.md +++ b/scnserver/README.md @@ -17,6 +17,8 @@ - diff my currently used scnsend script vs the one in the docs here + - Pagination for ListChannels / ListSubscriptions / ListClients / ListChannelSubscriptions / ListUserSubscriptions + ------------------------------------------------------------------------------------------------------------------------------- - in my script: use (backupname || hostname) for sendername diff --git a/scnserver/api/apierr/enums.go b/scnserver/api/apierr/enums.go index 34f3a37..d73179f 100644 --- a/scnserver/api/apierr/enums.go +++ b/scnserver/api/apierr/enums.go @@ -18,6 +18,7 @@ const ( BINDFAIL_QUERY_PARAM APIError = 1151 BINDFAIL_BODY_PARAM APIError = 1152 BINDFAIL_URI_PARAM APIError = 1153 + INVALID_BODY_PARAM APIError = 1161 INVALID_ENUM_VALUE APIError = 1171 NO_TITLE APIError = 1201 @@ -29,12 +30,13 @@ const ( CHANNEL_TOO_LONG APIError = 1207 CHANNEL_NAME_WOULD_CHANGE APIError = 1207 - USER_NOT_FOUND APIError = 1301 - CLIENT_NOT_FOUND APIError = 1302 - CHANNEL_NOT_FOUND APIError = 1303 - SUBSCRIPTION_NOT_FOUND APIError = 1304 - MESSAGE_NOT_FOUND APIError = 1305 - USER_AUTH_FAILED APIError = 1311 + USER_NOT_FOUND APIError = 1301 + CLIENT_NOT_FOUND APIError = 1302 + CHANNEL_NOT_FOUND APIError = 1303 + SUBSCRIPTION_NOT_FOUND APIError = 1304 + MESSAGE_NOT_FOUND APIError = 1305 + SUBSCRIPTION_USER_MISMATCH APIError = 1306 + USER_AUTH_FAILED APIError = 1311 NO_DEVICE_LINKED APIError = 1401 diff --git a/scnserver/api/handler/api.go b/scnserver/api/handler/api.go index 8e4af79..5565825 100644 --- a/scnserver/api/handler/api.go +++ b/scnserver/api/handler/api.go @@ -2,7 +2,6 @@ package handler import ( "blackforestbytes.com/simplecloudnotifier/api/apierr" - hl "blackforestbytes.com/simplecloudnotifier/api/apihighlight" "blackforestbytes.com/simplecloudnotifier/api/ginresp" "blackforestbytes.com/simplecloudnotifier/db" "blackforestbytes.com/simplecloudnotifier/db/cursortoken" @@ -501,8 +500,8 @@ func (h APIHandler) DeleteClient(g *gin.Context) ginresp.HTTPResponse { // @ID api-channels-list // @Tags API-v2 // -// @Param uid path int true "UserID" -// @Param selector query string true "Filter channels (default: owned)" Enums(owned, subscribed, all, subscribed_any, all_any) +// @Param uid path int true "UserID" +// @Param selector query string false "Filter channels (default: owned)" Enums(owned, subscribed, all, subscribed_any, all_any) // // @Success 200 {object} handler.ListChannels.response // @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" @@ -667,6 +666,10 @@ func (h APIHandler) CreateChannel(g *gin.Context) ginresp.HTTPResponse { return *permResp } + if b.Name == "" { + return ginresp.APIError(g, 400, apierr.INVALID_BODY_PARAM, "Missing parameter: name", nil) + } + channelDisplayName := h.app.NormalizeChannelDisplayName(b.Name) channelInternalName := h.app.NormalizeChannelInternalName(b.Name) @@ -677,17 +680,17 @@ func (h APIHandler) CreateChannel(g *gin.Context) ginresp.HTTPResponse { user, err := h.database.GetUser(ctx, u.UserID) if err == sql.ErrNoRows { - return ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, "User not found", nil) + return ginresp.APIError(g, 400, apierr.USER_NOT_FOUND, "User not found", nil) } if err != nil { - return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query user", err) + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query user", err) } if len(channelDisplayName) > user.MaxChannelNameLength() { - return ginresp.SendAPIError(g, 400, apierr.CHANNEL_TOO_LONG, hl.CHANNEL, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil) + return ginresp.APIError(g, 400, apierr.CHANNEL_TOO_LONG, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil) } if len(channelInternalName) > user.MaxChannelNameLength() { - return ginresp.SendAPIError(g, 400, apierr.CHANNEL_TOO_LONG, hl.CHANNEL, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil) + return ginresp.APIError(g, 400, apierr.CHANNEL_TOO_LONG, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil) } if channelExisting != nil { @@ -915,27 +918,27 @@ func (h APIHandler) ListChannelMessages(g *gin.Context) ginresp.HTTPResponse { // ListUserSubscriptions swaggerdoc // -// @Summary List all subscriptions of a user (incoming/owned) -// // @Description The possible values for 'selector' are: -// // @Description - "owner_all" All subscriptions (confirmed/unconfirmed) with the user as owner (= subscriptions he can use to read channels) -// // @Description - "owner_confirmed" Confirmed subscriptions with the user as owner -// // @Description - "owner_unconfirmed" Unconfirmed (Pending) subscriptions with the user as owner -// // @Description - "incoming_all" All subscriptions (confirmed/unconfirmed) from other users to channels of this user (= incoming subscriptions and subscription requests) -// // @Description - "incoming_confirmed" Confirmed subscriptions from other users to channels of this user -// // @Description - "incoming_unconfirmed" Unconfirmed subscriptions from other users to channels of this user (= requests) -// // -// @ID api-user-subscriptions-list -// @Tags API-v2 +// @Summary List all subscriptions of a user (incoming/owned) +// @Description The possible values for 'selector' are: +// @Description - "outgoing_all" All subscriptions (confirmed/unconfirmed) with the user as subscriber (= subscriptions he can use to read channels) +// @Description - "outgoing_confirmed" Confirmed subscriptions with the user as subscriber +// @Description - "outgoing_unconfirmed" Unconfirmed (Pending) subscriptions with the user as subscriber +// @Description - "incoming_all" All subscriptions (confirmed/unconfirmed) from other users to channels of this user (= incoming subscriptions and subscription requests) +// @Description - "incoming_confirmed" Confirmed subscriptions from other users to channels of this user +// @Description - "incoming_unconfirmed" Unconfirmed subscriptions from other users to channels of this user (= requests) // -// @Param uid path int true "UserID" -// @Param selector query string true "Filter subscribptions (default: owner_all)" Enums(owner_all, owner_confirmed, owner_unconfirmed, incoming_all, incoming_confirmed, incoming_unconfirmed) +// @ID api-user-subscriptions-list +// @Tags API-v2 // -// @Success 200 {object} handler.ListUserSubscriptions.response -// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" -// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" -// @Failure 500 {object} ginresp.apiError "internal server error" +// @Param uid path int true "UserID" +// @Param selector query string true "Filter subscriptions (default: owner_all)" Enums(outgoing_all, outgoing_confirmed, outgoing_unconfirmed, incoming_all, incoming_confirmed, incoming_unconfirmed) // -// @Router /api/users/{uid}/subscriptions [GET] +// @Success 200 {object} handler.ListUserSubscriptions.response +// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" +// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" +// @Failure 500 {object} ginresp.apiError "internal server error" +// +// @Router /api/users/{uid}/subscriptions [GET] func (h APIHandler) ListUserSubscriptions(g *gin.Context) ginresp.HTTPResponse { type uri struct { UserID models.UserID `uri:"uid"` @@ -964,48 +967,48 @@ func (h APIHandler) ListUserSubscriptions(g *gin.Context) ginresp.HTTPResponse { var res []models.Subscription var err error - if sel == "owner_all" { - - res, err = h.database.ListSubscriptionsByOwner(ctx, u.UserID, nil) - if err != nil { - return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) - } - - } else if sel == "owner_confirmed" { - - res, err = h.database.ListSubscriptionsByOwner(ctx, u.UserID, langext.Ptr(true)) - if err != nil { - return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) - } - - } else if sel == "owner_unconfirmed" { - - res, err = h.database.ListSubscriptionsByOwner(ctx, u.UserID, langext.Ptr(false)) - if err != nil { - return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) - } - - } else if sel == "incoming_all" { + if sel == "outgoing_all" { res, err = h.database.ListSubscriptionsBySubscriber(ctx, u.UserID, nil) if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) } - } else if sel == "incoming_confirmed" { + } else if sel == "outgoing_confirmed" { res, err = h.database.ListSubscriptionsBySubscriber(ctx, u.UserID, langext.Ptr(true)) if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) } - } else if sel == "incoming_unconfirmed" { + } else if sel == "outgoing_unconfirmed" { res, err = h.database.ListSubscriptionsBySubscriber(ctx, u.UserID, langext.Ptr(false)) if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) } + } else if sel == "incoming_all" { + + res, err = h.database.ListSubscriptionsByChannelOwner(ctx, u.UserID, nil) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) + } + + } else if sel == "incoming_confirmed" { + + res, err = h.database.ListSubscriptionsByChannelOwner(ctx, u.UserID, langext.Ptr(true)) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) + } + + } else if sel == "incoming_unconfirmed" { + + res, err = h.database.ListSubscriptionsByChannelOwner(ctx, u.UserID, langext.Ptr(false)) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err) + } + } else { return ginresp.APIError(g, 400, apierr.INVALID_ENUM_VALUE, "Invalid value for the [selector] parameter", nil) @@ -1111,9 +1114,8 @@ func (h APIHandler) GetSubscription(g *gin.Context) ginresp.HTTPResponse { if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscription", err) } - - if subscription.SubscriberUserID != u.UserID { - return ginresp.APIError(g, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil) + if subscription.SubscriberUserID != u.UserID && subscription.ChannelOwnerUserID != u.UserID { + return ginresp.APIError(g, 404, apierr.SUBSCRIPTION_USER_MISMATCH, "Subscription not found", nil) } return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, subscription.JSON())) @@ -1159,9 +1161,8 @@ func (h APIHandler) CancelSubscription(g *gin.Context) ginresp.HTTPResponse { if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscription", err) } - if subscription.SubscriberUserID != u.UserID && subscription.ChannelOwnerUserID != u.UserID { - return ginresp.APIError(g, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil) + return ginresp.APIError(g, 404, apierr.SUBSCRIPTION_USER_MISMATCH, "Subscription not found", nil) } err = h.database.DeleteSubscription(ctx, u.SubscriptionID) @@ -1174,27 +1175,29 @@ func (h APIHandler) CancelSubscription(g *gin.Context) ginresp.HTTPResponse { // CreateSubscription swaggerdoc // -// @Summary Creare/Request a subscription -// @ID api-subscriptions-create -// @Tags API-v2 +// @Summary Create/Request a subscription +// @Description Either [channel_owner_user_id, channel_internal_name] or [channel_id] must be supplied in the request body +// @ID api-subscriptions-create +// @Tags API-v2 // -// @Param uid path int true "UserID" -// @Param query_data query handler.CreateSubscription.query false " " -// @Param post_data body handler.CreateSubscription.body false " " +// @Param uid path int true "UserID" +// @Param query_data query handler.CreateSubscription.query false " " +// @Param post_data body handler.CreateSubscription.body false " " // -// @Success 200 {object} models.SubscriptionJSON -// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" -// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" -// @Failure 500 {object} ginresp.apiError "internal server error" +// @Success 200 {object} models.SubscriptionJSON +// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" +// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" +// @Failure 500 {object} ginresp.apiError "internal server error" // -// @Router /api/users/{uid}/subscriptions [POST] +// @Router /api/users/{uid}/subscriptions [POST] func (h APIHandler) CreateSubscription(g *gin.Context) ginresp.HTTPResponse { type uri struct { UserID models.UserID `uri:"uid"` } type body struct { - ChannelOwnerUserID models.UserID `form:"channel_owner_user_id" binding:"required"` - Channel string `form:"channel_name" binding:"required"` + ChannelOwnerUserID *models.UserID `json:"channel_owner_user_id"` + ChannelInternalName *string `json:"channel_internal_name"` + ChannelID *models.ChannelID `json:"channel_id"` } type query struct { ChanSubscribeKey *string `json:"chan_subscribe_key" form:"chan_subscribe_key"` @@ -1213,21 +1216,45 @@ func (h APIHandler) CreateSubscription(g *gin.Context) ginresp.HTTPResponse { return *permResp } - channelInternalName := h.app.NormalizeChannelInternalName(b.Channel) + var channel models.Channel + + if b.ChannelOwnerUserID != nil && b.ChannelInternalName != nil && b.ChannelID == nil { + + channelInternalName := h.app.NormalizeChannelInternalName(*b.ChannelInternalName) + + outchannel, err := h.database.GetChannelByName(ctx, *b.ChannelOwnerUserID, channelInternalName) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err) + } + if outchannel == nil { + return ginresp.APIError(g, 400, apierr.CHANNEL_NOT_FOUND, "Channel not found", err) + } + + channel = *outchannel + + } else if b.ChannelOwnerUserID == nil && b.ChannelInternalName == nil && b.ChannelID != nil { + + outchannel, err := h.database.GetChannelByID(ctx, *b.ChannelID) + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err) + } + if outchannel == nil { + return ginresp.APIError(g, 400, apierr.CHANNEL_NOT_FOUND, "Channel not found", err) + } + + channel = *outchannel + + } else { + + return ginresp.APIError(g, 400, apierr.INVALID_BODY_PARAM, "Must either supply [channel_owner_user_id, channel_internal_name] or [channel_id]", nil) - channel, err := h.database.GetChannelByName(ctx, b.ChannelOwnerUserID, channelInternalName) - if err != nil { - return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err) - } - if channel == nil { - return ginresp.APIError(g, 400, apierr.CHANNEL_NOT_FOUND, "Channel not found", err) } if channel.OwnerUserID != u.UserID && (q.ChanSubscribeKey == nil || *q.ChanSubscribeKey != channel.SubscribeKey) { - ginresp.APIError(g, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil) + return ginresp.APIError(g, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil) } - sub, err := h.database.CreateSubscription(ctx, u.UserID, *channel, channel.OwnerUserID == u.UserID) + sub, err := h.database.CreateSubscription(ctx, u.UserID, channel, channel.OwnerUserID == u.UserID) if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create subscription", err) } @@ -1279,9 +1306,8 @@ func (h APIHandler) UpdateSubscription(g *gin.Context) ginresp.HTTPResponse { if err != nil { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscription", err) } - - if subscription.ChannelOwnerUserID != u.UserID { - return ginresp.APIError(g, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil) + if subscription.SubscriberUserID != u.UserID { + return ginresp.APIError(g, 404, apierr.SUBSCRIPTION_USER_MISMATCH, "Subscription not found", nil) } if b.Confirmed != nil { diff --git a/scnserver/api/handler/common.go b/scnserver/api/handler/common.go index d663a27..d9aeedd 100644 --- a/scnserver/api/handler/common.go +++ b/scnserver/api/handler/common.go @@ -124,7 +124,7 @@ func (h CommonHandler) Health(g *gin.Context) ginresp.HTTPResponse { _, libVersionNumber, _ := sqlite3.Version() if libVersionNumber < 3039000 { - ginresp.InternalError(errors.New("sqlite version too low")) + return ginresp.InternalError(errors.New("sqlite version too low")) } err := h.app.Database.Ping(ctx) diff --git a/scnserver/db/channels.go b/scnserver/db/channels.go index f2b47f8..32e7e9e 100644 --- a/scnserver/db/channels.go +++ b/scnserver/db/channels.go @@ -57,6 +57,30 @@ func (db *Database) GetChannelByNameAndSendKey(ctx TxContext, chanName string, s return &channel, nil } +func (db *Database) GetChannelByID(ctx TxContext, chanid models.ChannelID) (*models.Channel, error) { + tx, err := ctx.GetOrCreateTransaction(db) + if err != nil { + return nil, err + } + + rows, err := tx.Query(ctx, "SELECT * FROM channels WHERE channel_id = :cid LIMIT 1", sq.PP{ + "cid": chanid, + }) + if err != nil { + return nil, err + } + + channel, err := models.DecodeChannel(rows) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + return &channel, nil +} + func (db *Database) CreateChannel(ctx TxContext, userid models.UserID, dispName string, intName string, subscribeKey string, sendKey string) (models.Channel, error) { tx, err := ctx.GetOrCreateTransaction(db) if err != nil { diff --git a/scnserver/db/dbtools/preprocessor.go b/scnserver/db/dbtools/preprocessor.go index b138fdf..92776ec 100644 --- a/scnserver/db/dbtools/preprocessor.go +++ b/scnserver/db/dbtools/preprocessor.go @@ -206,12 +206,7 @@ func (pp *DBPreprocessor) getTableColumns(ctx context.Context, tablename string) } type res struct { - CID int64 `db:"cid"` - Name string `db:"name"` - Type string `db:"type"` - NotNull int `db:"notnull"` - DFLT *string `db:"dflt_value"` - PK int `db:"pk"` + Name string `db:"name"` } rows, err := pp.db.Query(ctx, "PRAGMA table_info('"+tablename+"');", sq.PP{}) @@ -219,7 +214,7 @@ func (pp *DBPreprocessor) getTableColumns(ctx context.Context, tablename string) return nil, err } - resrows, err := sq.ScanAll[res](rows, true) + resrows, err := sq.ScanAll[res](rows, sq.SModeFast, sq.Unsafe, true) if err != nil { return nil, err } diff --git a/scnserver/db/subscriptions.go b/scnserver/db/subscriptions.go index 0873f1b..8e4682e 100644 --- a/scnserver/db/subscriptions.go +++ b/scnserver/db/subscriptions.go @@ -62,7 +62,7 @@ func (db *Database) ListSubscriptionsByChannel(ctx TxContext, channelID models.C return data, nil } -func (db *Database) ListSubscriptionsByOwner(ctx TxContext, ownerUserID models.UserID, confirmed *bool) ([]models.Subscription, error) { +func (db *Database) ListSubscriptionsByChannelOwner(ctx TxContext, ownerUserID models.UserID, confirmed *bool) ([]models.Subscription, error) { tx, err := ctx.GetOrCreateTransaction(db) if err != nil { return nil, err diff --git a/scnserver/go.mod b/scnserver/go.mod index 0c51b88..e878e80 100644 --- a/scnserver/go.mod +++ b/scnserver/go.mod @@ -8,7 +8,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.16 github.com/rs/zerolog v1.28.0 github.com/swaggo/swag v1.8.7 - gogs.mikescher.com/BlackForestBytes/goext v0.0.42 + gogs.mikescher.com/BlackForestBytes/goext v0.0.46 ) require ( diff --git a/scnserver/go.sum b/scnserver/go.sum index 5d51038..92f37b7 100644 --- a/scnserver/go.sum +++ b/scnserver/go.sum @@ -122,6 +122,12 @@ gogs.mikescher.com/BlackForestBytes/goext v0.0.41 h1:3p/MtkHZ2gulSdizXql3VnFf2v7 gogs.mikescher.com/BlackForestBytes/goext v0.0.41/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g= gogs.mikescher.com/BlackForestBytes/goext v0.0.42 h1:u6+pDRrL9wSvJG7gVsGUO4dA54qzac5LsqoXqi6oo9E= gogs.mikescher.com/BlackForestBytes/goext v0.0.42/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g= +gogs.mikescher.com/BlackForestBytes/goext v0.0.44 h1:YC8SrQk1BEDR5wCdLZ2trnNvkUg/sssW94XYKrsKyc4= +gogs.mikescher.com/BlackForestBytes/goext v0.0.44/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g= +gogs.mikescher.com/BlackForestBytes/goext v0.0.45 h1:1naABIgSa5hhWPT7kYAAEeIUBNLo7nVvE6/kz9LoY9Q= +gogs.mikescher.com/BlackForestBytes/goext v0.0.45/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g= +gogs.mikescher.com/BlackForestBytes/goext v0.0.46 h1:7nV9RKnnz/qgkVWvlj4MOAITbe+Gas1niVQgvbHnNk8= +gogs.mikescher.com/BlackForestBytes/goext v0.0.46/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= diff --git a/scnserver/models/channel.go b/scnserver/models/channel.go index 6ac00b4..5da738b 100644 --- a/scnserver/models/channel.go +++ b/scnserver/models/channel.go @@ -117,7 +117,7 @@ func (c ChannelWithSubscriptionDB) Model() ChannelWithSubscription { } func DecodeChannel(r *sqlx.Rows) (Channel, error) { - data, err := sq.ScanSingle[ChannelDB](r, true) + data, err := sq.ScanSingle[ChannelDB](r, sq.SModeFast, sq.Safe, true) if err != nil { return Channel{}, err } @@ -125,7 +125,7 @@ func DecodeChannel(r *sqlx.Rows) (Channel, error) { } func DecodeChannels(r *sqlx.Rows) ([]Channel, error) { - data, err := sq.ScanAll[ChannelDB](r, true) + data, err := sq.ScanAll[ChannelDB](r, sq.SModeFast, sq.Safe, true) if err != nil { return nil, err } @@ -133,7 +133,7 @@ func DecodeChannels(r *sqlx.Rows) ([]Channel, error) { } func DecodeChannelWithSubscription(r *sqlx.Rows) (ChannelWithSubscription, error) { - data, err := sq.ScanSingle[ChannelWithSubscriptionDB](r, true) + data, err := sq.ScanSingle[ChannelWithSubscriptionDB](r, sq.SModeExtended, sq.Safe, true) if err != nil { return ChannelWithSubscription{}, err } @@ -141,7 +141,7 @@ func DecodeChannelWithSubscription(r *sqlx.Rows) (ChannelWithSubscription, error } func DecodeChannelsWithSubscription(r *sqlx.Rows) ([]ChannelWithSubscription, error) { - data, err := sq.ScanAll[ChannelWithSubscriptionDB](r, true) + data, err := sq.ScanAll[ChannelWithSubscriptionDB](r, sq.SModeExtended, sq.Safe, true) if err != nil { return nil, err } diff --git a/scnserver/models/client.go b/scnserver/models/client.go index 242d059..6eee91b 100644 --- a/scnserver/models/client.go +++ b/scnserver/models/client.go @@ -69,7 +69,7 @@ func (c ClientDB) Model() Client { } func DecodeClient(r *sqlx.Rows) (Client, error) { - data, err := sq.ScanSingle[ClientDB](r, true) + data, err := sq.ScanSingle[ClientDB](r, sq.SModeFast, sq.Safe, true) if err != nil { return Client{}, err } @@ -77,7 +77,7 @@ func DecodeClient(r *sqlx.Rows) (Client, error) { } func DecodeClients(r *sqlx.Rows) ([]Client, error) { - data, err := sq.ScanAll[ClientDB](r, true) + data, err := sq.ScanAll[ClientDB](r, sq.SModeFast, sq.Safe, true) if err != nil { return nil, err } diff --git a/scnserver/models/delivery.go b/scnserver/models/delivery.go index dd09a98..7b66d62 100644 --- a/scnserver/models/delivery.go +++ b/scnserver/models/delivery.go @@ -89,7 +89,7 @@ func (d DeliveryDB) Model() Delivery { } func DecodeDelivery(r *sqlx.Rows) (Delivery, error) { - data, err := sq.ScanSingle[DeliveryDB](r, true) + data, err := sq.ScanSingle[DeliveryDB](r, sq.SModeFast, sq.Safe, true) if err != nil { return Delivery{}, err } @@ -97,7 +97,7 @@ func DecodeDelivery(r *sqlx.Rows) (Delivery, error) { } func DecodeDeliveries(r *sqlx.Rows) ([]Delivery, error) { - data, err := sq.ScanAll[DeliveryDB](r, true) + data, err := sq.ScanAll[DeliveryDB](r, sq.SModeFast, sq.Safe, true) if err != nil { return nil, err } diff --git a/scnserver/models/message.go b/scnserver/models/message.go index 0ae3ca2..091a3fb 100644 --- a/scnserver/models/message.go +++ b/scnserver/models/message.go @@ -146,7 +146,7 @@ func (m MessageDB) Model() Message { } func DecodeMessage(r *sqlx.Rows) (Message, error) { - data, err := sq.ScanSingle[MessageDB](r, true) + data, err := sq.ScanSingle[MessageDB](r, sq.SModeFast, sq.Safe, true) if err != nil { return Message{}, err } @@ -154,7 +154,7 @@ func DecodeMessage(r *sqlx.Rows) (Message, error) { } func DecodeMessages(r *sqlx.Rows) ([]Message, error) { - data, err := sq.ScanAll[MessageDB](r, true) + data, err := sq.ScanAll[MessageDB](r, sq.SModeFast, sq.Safe, true) if err != nil { return nil, err } diff --git a/scnserver/models/subscription.go b/scnserver/models/subscription.go index af8b56f..edacbe9 100644 --- a/scnserver/models/subscription.go +++ b/scnserver/models/subscription.go @@ -62,7 +62,7 @@ func (s SubscriptionDB) Model() Subscription { } func DecodeSubscription(r *sqlx.Rows) (Subscription, error) { - data, err := sq.ScanSingle[SubscriptionDB](r, true) + data, err := sq.ScanSingle[SubscriptionDB](r, sq.SModeFast, sq.Safe, true) if err != nil { return Subscription{}, err } @@ -70,7 +70,7 @@ func DecodeSubscription(r *sqlx.Rows) (Subscription, error) { } func DecodeSubscriptions(r *sqlx.Rows) ([]Subscription, error) { - data, err := sq.ScanAll[SubscriptionDB](r, true) + data, err := sq.ScanAll[SubscriptionDB](r, sq.SModeFast, sq.Safe, true) if err != nil { return nil, err } diff --git a/scnserver/models/user.go b/scnserver/models/user.go index d665203..4ae1437 100644 --- a/scnserver/models/user.go +++ b/scnserver/models/user.go @@ -163,7 +163,7 @@ func (u UserDB) Model() User { } func DecodeUser(r *sqlx.Rows) (User, error) { - data, err := sq.ScanSingle[UserDB](r, true) + data, err := sq.ScanSingle[UserDB](r, sq.SModeFast, sq.Safe, true) if err != nil { return User{}, err } @@ -171,7 +171,7 @@ func DecodeUser(r *sqlx.Rows) (User, error) { } func DecodeUsers(r *sqlx.Rows) ([]User, error) { - data, err := sq.ScanAll[UserDB](r, true) + data, err := sq.ScanAll[UserDB](r, sq.SModeFast, sq.Safe, true) if err != nil { return nil, err } diff --git a/scnserver/swagger/swagger.json b/scnserver/swagger/swagger.json index 9ef4466..32b1b9d 100644 --- a/scnserver/swagger/swagger.json +++ b/scnserver/swagger/swagger.json @@ -1176,8 +1176,7 @@ "type": "string", "description": "Filter channels (default: owned)", "name": "selector", - "in": "query", - "required": true + "in": "query" } ], "responses": { @@ -1743,6 +1742,7 @@ }, "/api/users/{uid}/subscriptions": { "get": { + "description": "The possible values for 'selector' are:\n- \"owner_all\" All subscriptions (confirmed/unconfirmed) with the user as owner (= subscriptions he can use to read channels)\n- \"owner_confirmed\" Confirmed subscriptions with the user as owner\n- \"owner_unconfirmed\" Unconfirmed (Pending) subscriptions with the user as owner\n- \"incoming_all\" All subscriptions (confirmed/unconfirmed) from other users to channels of this user (= incoming subscriptions and subscription requests)\n- \"incoming_confirmed\" Confirmed subscriptions from other users to channels of this user\n- \"incoming_unconfirmed\" Unconfirmed subscriptions from other users to channels of this user (= requests)", "tags": [ "API-v2" ], @@ -1766,7 +1766,7 @@ "incoming_unconfirmed" ], "type": "string", - "description": "Filter subscribptions (default: owner_all)", + "description": "Filter subscriptions (default: owner_all)", "name": "selector", "in": "query", "required": true @@ -1800,10 +1800,11 @@ } }, "post": { + "description": "Either [channel_owner_user_id, channel_internal_name] or [channel_id] must be supplied in the request body", "tags": [ "API-v2" ], - "summary": "Creare/Request a subscription", + "summary": "Create/Request a subscription", "operationId": "api-subscriptions-create", "parameters": [ { @@ -2410,11 +2411,15 @@ "handler.CreateSubscription.body": { "type": "object", "required": [ - "channel", + "channel_id", + "channel_internal_name", "channel_owner_user_id" ], "properties": { - "channel": { + "channel_id": { + "type": "integer" + }, + "channel_internal_name": { "type": "string" }, "channel_owner_user_id": { diff --git a/scnserver/swagger/swagger.yaml b/scnserver/swagger/swagger.yaml index fb43d35..fff7d6f 100644 --- a/scnserver/swagger/swagger.yaml +++ b/scnserver/swagger/swagger.yaml @@ -60,12 +60,15 @@ definitions: type: object handler.CreateSubscription.body: properties: - channel: + channel_id: + type: integer + channel_internal_name: type: string channel_owner_user_id: type: integer required: - - channel + - channel_id + - channel_internal_name - channel_owner_user_id type: object handler.CreateUser.body: @@ -1315,7 +1318,6 @@ paths: - all_any in: query name: selector - required: true type: string responses: "200": @@ -1697,6 +1699,14 @@ paths: - API-v2 /api/users/{uid}/subscriptions: get: + description: |- + The possible values for 'selector' are: + - "owner_all" All subscriptions (confirmed/unconfirmed) with the user as owner (= subscriptions he can use to read channels) + - "owner_confirmed" Confirmed subscriptions with the user as owner + - "owner_unconfirmed" Unconfirmed (Pending) subscriptions with the user as owner + - "incoming_all" All subscriptions (confirmed/unconfirmed) from other users to channels of this user (= incoming subscriptions and subscription requests) + - "incoming_confirmed" Confirmed subscriptions from other users to channels of this user + - "incoming_unconfirmed" Unconfirmed subscriptions from other users to channels of this user (= requests) operationId: api-user-subscriptions-list parameters: - description: UserID @@ -1704,7 +1714,7 @@ paths: name: uid required: true type: integer - - description: 'Filter subscribptions (default: owner_all)' + - description: 'Filter subscriptions (default: owner_all)' enum: - owner_all - owner_confirmed @@ -1737,6 +1747,8 @@ paths: tags: - API-v2 post: + description: Either [channel_owner_user_id, channel_internal_name] or [channel_id] + must be supplied in the request body operationId: api-subscriptions-create parameters: - description: UserID @@ -1769,7 +1781,7 @@ paths: description: internal server error schema: $ref: '#/definitions/ginresp.apiError' - summary: Creare/Request a subscription + summary: Create/Request a subscription tags: - API-v2 /api/users/{uid}/subscriptions/{sid}: diff --git a/scnserver/test/channel_test.go b/scnserver/test/channel_test.go index 41545a0..e071a1a 100644 --- a/scnserver/test/channel_test.go +++ b/scnserver/test/channel_test.go @@ -161,10 +161,10 @@ func TestListChannelsOwned(t *testing.T) { } testdata := map[int][]string{ - 0: {"main", "chattingchamber", "unicdhll", "promotions", "reminders"}, + 0: {"main", "chatting chamber", "unicôdé häll \U0001f92a", "promotions", "reminders"}, 1: {"main", "private"}, 2: {"main", "ü", "ö", "ä"}, - 3: {"main", "innovations", "reminders"}, + 3: {"main", "\U0001f5ff", "innovations", "reminders"}, 4: {"main"}, 5: {"main", "test1", "test2", "test3", "test4", "test5"}, 6: {"main", "security", "lipsum"}, @@ -175,8 +175,8 @@ func TestListChannelsOwned(t *testing.T) { 11: {"promotions"}, 12: {}, 13: {}, - 14: {"", "chan_self_subscribed", "chan_self_unsub"}, //TODO these two have the interesting cases - 15: {"", "chan_other_nosub", "chan_other_request", "chan_other_request", "chan_other_accepted"}, //TODO these two have the interesting cases + 14: {"main", "chan_self_subscribed", "chan_self_unsub"}, //TODO these two have the interesting cases + 15: {"main", "chan_other_nosub", "chan_other_request", "chan_other_accepted"}, //TODO these two have the interesting cases } for k, v := range testdata { @@ -186,19 +186,143 @@ func TestListChannelsOwned(t *testing.T) { } func TestListChannelsSubscribedAny(t *testing.T) { - t.SkipNow() //TODO + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitDefaultData(t, ws) + + type chanlist struct { + Channels []gin.H `json:"channels"` + } + + testdata := map[int][]string{ + 0: {"main", "chatting chamber", "unicôdé häll \U0001f92a", "promotions", "reminders"}, + 1: {"main", "private"}, + 2: {"main", "ü", "ö", "ä"}, + 3: {"main", "\U0001f5ff", "innovations", "reminders"}, + 4: {"main"}, + 5: {"main", "test1", "test2", "test3", "test4", "test5"}, + 6: {"main", "security", "lipsum"}, + 7: {"main"}, + 8: {"main"}, + 9: {"main", "manual@chan"}, + 10: {"main"}, + 11: {"promotions"}, + 12: {}, + 13: {}, + 14: {"main", "chan_self_subscribed", "chan_self_unsub"}, //TODO these two have the interesting cases + 15: {"main", "chan_other_nosub", "chan_other_request", "chan_other_accepted"}, //TODO these two have the interesting cases + } + + for k, v := range testdata { + r0 := tt.RequestAuthGet[chanlist](t, data.User[k].AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/channels", data.User[k].UID)) + tt.AssertMappedSet(t, fmt.Sprintf("%d->chanlist", k), v, r0.Channels, "internal_name") + } } func TestListChannelsAllAny(t *testing.T) { - t.SkipNow() //TODO + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitDefaultData(t, ws) + + type chanlist struct { + Channels []gin.H `json:"channels"` + } + + testdata := map[int][]string{ + 0: {"main", "chatting chamber", "unicôdé häll \U0001f92a", "promotions", "reminders"}, + 1: {"main", "private"}, + 2: {"main", "ü", "ö", "ä"}, + 3: {"main", "\U0001f5ff", "innovations", "reminders"}, + 4: {"main"}, + 5: {"main", "test1", "test2", "test3", "test4", "test5"}, + 6: {"main", "security", "lipsum"}, + 7: {"main"}, + 8: {"main"}, + 9: {"main", "manual@chan"}, + 10: {"main"}, + 11: {"promotions"}, + 12: {}, + 13: {}, + 14: {"main", "chan_self_subscribed", "chan_self_unsub"}, //TODO these two have the interesting cases + 15: {"main", "chan_other_nosub", "chan_other_request", "chan_other_accepted"}, //TODO these two have the interesting cases + } + + for k, v := range testdata { + r0 := tt.RequestAuthGet[chanlist](t, data.User[k].AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/channels", data.User[k].UID)) + tt.AssertMappedSet(t, fmt.Sprintf("%d->chanlist", k), v, r0.Channels, "internal_name") + } } func TestListChannelsSubscribed(t *testing.T) { - t.SkipNow() //TODO + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitDefaultData(t, ws) + + type chanlist struct { + Channels []gin.H `json:"channels"` + } + + testdata := map[int][]string{ + 0: {"main", "chatting chamber", "unicôdé häll \U0001f92a", "promotions", "reminders"}, + 1: {"main", "private"}, + 2: {"main", "ü", "ö", "ä"}, + 3: {"main", "\U0001f5ff", "innovations", "reminders"}, + 4: {"main"}, + 5: {"main", "test1", "test2", "test3", "test4", "test5"}, + 6: {"main", "security", "lipsum"}, + 7: {"main"}, + 8: {"main"}, + 9: {"main", "manual@chan"}, + 10: {"main"}, + 11: {"promotions"}, + 12: {}, + 13: {}, + 14: {"main", "chan_self_subscribed", "chan_self_unsub"}, //TODO these two have the interesting cases + 15: {"main", "chan_other_nosub", "chan_other_request", "chan_other_accepted"}, //TODO these two have the interesting cases + } + + for k, v := range testdata { + r0 := tt.RequestAuthGet[chanlist](t, data.User[k].AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/channels", data.User[k].UID)) + tt.AssertMappedSet(t, fmt.Sprintf("%d->chanlist", k), v, r0.Channels, "internal_name") + } } func TestListChannelsAll(t *testing.T) { - t.SkipNow() //TODO + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitDefaultData(t, ws) + + type chanlist struct { + Channels []gin.H `json:"channels"` + } + + testdata := map[int][]string{ + 0: {"main", "chatting chamber", "unicôdé häll \U0001f92a", "promotions", "reminders"}, + 1: {"main", "private"}, + 2: {"main", "ü", "ö", "ä"}, + 3: {"main", "\U0001f5ff", "innovations", "reminders"}, + 4: {"main"}, + 5: {"main", "test1", "test2", "test3", "test4", "test5"}, + 6: {"main", "security", "lipsum"}, + 7: {"main"}, + 8: {"main"}, + 9: {"main", "manual@chan"}, + 10: {"main"}, + 11: {"promotions"}, + 12: {}, + 13: {}, + 14: {"main", "chan_self_subscribed", "chan_self_unsub"}, //TODO these two have the interesting cases + 15: {"main", "chan_other_nosub", "chan_other_request", "chan_other_accepted"}, //TODO these two have the interesting cases + } + + for k, v := range testdata { + r0 := tt.RequestAuthGet[chanlist](t, data.User[k].AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/channels", data.User[k].UID)) + tt.AssertMappedSet(t, fmt.Sprintf("%d->chanlist", k), v, r0.Channels, "internal_name") + } } //TODO test missing channel-xx methods diff --git a/scnserver/test/util/factory.go b/scnserver/test/util/factory.go index e52752a..9a77907 100644 --- a/scnserver/test/util/factory.go +++ b/scnserver/test/util/factory.go @@ -257,8 +257,8 @@ var messageExamples = []msgex{ {11, "Promotions", "localhost", P2, SKEY, "New Product Launch: Introducing Our Latest Innovation", "We are excited to announce the release of our newest product, designed to revolutionize the industry. Don't miss out on this game-changing technology.", timeext.FromHours(-12.21)}, {11, "Promotions", "#S0", P0, SKEY, "Limited Time Offer: Get 50% Off Your Next Purchase", "For a limited time, take advantage of our special offer and get half off your next purchase. Don't miss out on this amazing deal.", 0}, {11, "Promotions", "#S0", P2, SKEY, "Customer Appreciation Sale: Save Up to 75% on Your Favorite Products", "", 0}, - {11, "Promotions", "", P0, SKEY, "Sign Up for Our Newsletter and Save 10% on Your Next Order", "", 0}, - {11, "Promotions", "", PX, AKEY, "New Arrivals: Check Out Our Latest Collection", "We've just added new items to our collection and we think you'll love them. Take a look and see what's new in fashion, home decor, and more.", 0}, + {11, " Promotions", "", P0, SKEY, "Sign Up for Our Newsletter and Save 10% on Your Next Order", "", 0}, + {11, "Promotions ", "", PX, AKEY, "New Arrivals: Check Out Our Latest Collection", "We've just added new items to our collection and we think you'll love them. Take a look and see what's new in fashion, home decor, and more.", 0}, {11, "Promotions", "", PX, AKEY, "Join Our Rewards Program and Earn Points on Every Purchase", "Sign up for our rewards program and earn points on every purchase you make. Redeem your points for discounts, free products, and more.", 0}, {11, "Promotions", "#S0", P0, SKEY, "Seasonal Special: Save on Your Favorite Fall Products", "As the leaves change color and the air gets cooler, we have the perfect products to help you enjoy the season. Take advantage of our special offers and save on your favorite fall products.", 0}, {11, "Promotions", "192.168.0.1", P1, AKEY, "Refer a Friend and Save on Your Next Order", "Share the love and refer a friend to our store. When they make a purchase, you'll receive a discount on your next order. It's a win-win for both of you.", 0}, @@ -384,10 +384,10 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData { // Sub/Unsub for Users 12+13 { - //TODO User 12 unsubscribe from 12:chan_self_unsub - //TODO User 13 request-subscribe to 13:chan_other_request - //TODO User 13 request-subscribe to 13:chan_other_accepted - //TODO User 13 accept subscription from user 12 to 13:chan_other_accepted + doUnsubscribe(t, baseUrl, users[14], users[14], "chan_self_unsub") + doSubscribe(t, baseUrl, users[14], users[15], "chan_other_request") + doSubscribe(t, baseUrl, users[14], users[15], "chan_other_accepted") + doAcceptSub(t, baseUrl, users[15], users[14], "chan_other_accepted") } success = true @@ -395,6 +395,78 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData { return DefData{User: users} } +func doSubscribe(t *testing.T, baseUrl string, user Userdat, chanOwner Userdat, chanInternalName string) { + + if user == chanOwner { + + RequestAuthPost[Void](t, user.AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/channels", user.UID), gin.H{ + "channel_owner_user_id": chanOwner.UID, + "channel_internal_name": chanInternalName, + }) + + } else { + type chanlist struct { + Channels []gin.H `json:"channels"` + } + + clist := RequestAuthGet[chanlist](t, chanOwner.AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/channels?selector=owned", chanOwner.UID)) + + var chandat gin.H + for _, v := range clist.Channels { + if v["internal_name"].(string) == chanInternalName { + chandat = v + break + } + } + + RequestAuthPost[Void](t, user.AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/subscriptions?chan_subscribe_key=%s", user.UID, chandat["subscribe_key"].(string)), gin.H{ + "channel_id": chandat["channel_id"].(float64), + }) + + } + +} + +func doUnsubscribe(t *testing.T, baseUrl string, user Userdat, chanOwner Userdat, chanInternalName string) { + + type chanlist struct { + Subscriptions []gin.H `json:"subscriptions"` + } + + slist := RequestAuthGet[chanlist](t, user.AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/subscriptions?selector=outgoing_confirmed", user.UID)) + + var subdat gin.H + for _, v := range slist.Subscriptions { + if v["channel_internal_name"].(string) == chanInternalName && int64(v["channel_owner_user_id"].(float64)) == chanOwner.UID { + subdat = v + break + } + } + + RequestAuthDelete[Void](t, user.AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/subscriptions/%v", user.UID, subdat["subscription_id"]), gin.H{}) + +} + +func doAcceptSub(t *testing.T, baseUrl string, user Userdat, subscriber Userdat, chanInternalName string) { + + type chanlist struct { + Subscriptions []gin.H `json:"subscriptions"` + } + + slist := RequestAuthGet[chanlist](t, user.AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/subscriptions?selector=incoming_unconfirmed", user.UID)) + + var subdat gin.H + for _, v := range slist.Subscriptions { + if v["channel_internal_name"].(string) == chanInternalName && int64(v["subscriber_user_id"].(float64)) == subscriber.UID { + subdat = v + break + } + } + + RequestAuthDelete[Void](t, user.AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/subscriptions/%v", user.UID, subdat["subscription_id"]), gin.H{}) + +} + func Lipsum(seed int64, paracount int) string { return loremipsum.NewWithSeed(seed).Paragraphs(paracount) } diff --git a/scnserver/test/util/requests.go b/scnserver/test/util/requests.go index 7c9aa30..e7199ad 100644 --- a/scnserver/test/util/requests.go +++ b/scnserver/test/util/requests.go @@ -152,7 +152,7 @@ func RequestAny[TResult any](t *testing.T, akey string, method string, baseURL s TPrintln("") if resp.StatusCode != 200 { - TestFail(t, "Statuscode != 200") + TestFailFmt(t, "Statuscode != 200 (actual = %d)", resp.StatusCode) } var data TResult