Fix SQL unmarshalling of optional nested structs (LEFT JOIN)

This commit is contained in:
Mike Schwörer 2022-12-22 12:43:40 +01:00
parent 0cb2a977a0
commit 0112d681ac
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
20 changed files with 403 additions and 135 deletions

View File

@ -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

View File

@ -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
@ -34,6 +35,7 @@ const (
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

View File

@ -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"
@ -502,7 +501,7 @@ func (h APIHandler) DeleteClient(g *gin.Context) ginresp.HTTPResponse {
// @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 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 {
@ -916,19 +919,19 @@ 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)
// //
// @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)
//
// @ID api-user-subscriptions-list
// @Tags API-v2
//
// @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)
// @Param selector query string true "Filter subscriptions (default: owner_all)" Enums(outgoing_all, outgoing_confirmed, outgoing_unconfirmed, incoming_all, incoming_confirmed, incoming_unconfirmed)
//
// @Success 200 {object} handler.ListUserSubscriptions.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
@ -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,7 +1175,8 @@ func (h APIHandler) CancelSubscription(g *gin.Context) ginresp.HTTPResponse {
// CreateSubscription swaggerdoc
//
// @Summary Creare/Request a subscription
// @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
//
@ -1193,8 +1195,9 @@ func (h APIHandler) CreateSubscription(g *gin.Context) ginresp.HTTPResponse {
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
channel, err := h.database.GetChannelByName(ctx, b.ChannelOwnerUserID, channelInternalName)
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 channel == nil {
if outchannel == 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)
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)
}
sub, err := h.database.CreateSubscription(ctx, u.UserID, *channel, channel.OwnerUserID == u.UserID)
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)
}
if channel.OwnerUserID != u.UserID && (q.ChanSubscribeKey == nil || *q.ChanSubscribeKey != channel.SubscribeKey) {
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)
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 {

View File

@ -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)

View File

@ -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 {

View File

@ -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"`
}
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
}

View File

@ -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

View File

@ -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 (

View File

@ -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=

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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": {

View File

@ -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}:

View File

@ -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

View File

@ -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)
}

View File

@ -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