From 2889b562e64993482bd13fd806af8ed0865bab8b Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 14 Feb 2026 11:52:56 +0900 Subject: refactor: migrate API from GraphQL to REST (TypeSpec/OpenAPI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the entire GraphQL stack (gqlgen, urql, graphql-codegen) with a TypeSpec → OpenAPI 3.x pipeline using oapi-codegen for Go server stubs and openapi-fetch + openapi-typescript for the frontend client. Co-Authored-By: Claude Opus 4.6 --- backend/api/converters.go | 79 +++ backend/api/echo_context.go | 21 + backend/api/generated.go | 1162 +++++++++++++++++++++++++++++++++++++++ backend/api/handler.go | 16 + backend/api/handler_articles.go | 245 +++++++++ backend/api/handler_auth.go | 73 +++ backend/api/handler_feeds.go | 185 +++++++ backend/api/oapi-codegen.yaml | 6 + 8 files changed, 1787 insertions(+) create mode 100644 backend/api/converters.go create mode 100644 backend/api/echo_context.go create mode 100644 backend/api/generated.go create mode 100644 backend/api/handler.go create mode 100644 backend/api/handler_articles.go create mode 100644 backend/api/handler_auth.go create mode 100644 backend/api/handler_feeds.go create mode 100644 backend/api/oapi-codegen.yaml (limited to 'backend/api') diff --git a/backend/api/converters.go b/backend/api/converters.go new file mode 100644 index 0000000..0ccbdc0 --- /dev/null +++ b/backend/api/converters.go @@ -0,0 +1,79 @@ +package api + +import ( + "strconv" + + "undef.ninja/x/feedaka/db" +) + +func dbFeedToAPI(f db.Feed, unreadCount int64) Feed { + return Feed{ + Id: strconv.FormatInt(f.ID, 10), + Url: f.Url, + Title: f.Title, + FetchedAt: f.FetchedAt, + IsSubscribed: f.IsSubscribed == 1, + UnreadCount: int32(unreadCount), + } +} + +type articleRow struct { + ID int64 + FeedID int64 + Guid string + Title string + Url string + IsRead int64 + FeedID2 int64 + FeedUrl string + FeedTitle string + FeedIsSubscribed int64 +} + +func toArticleRow(r any) articleRow { + switch v := r.(type) { + case db.GetArticlesPaginatedRow: + return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed} + case db.GetArticlesPaginatedAfterRow: + return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed} + case db.GetArticlesByFeedPaginatedRow: + return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed} + case db.GetArticlesByFeedPaginatedAfterRow: + return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed} + default: + panic("unexpected row type") + } +} + +func rowToArticle(row articleRow) Article { + return Article{ + Id: strconv.FormatInt(row.ID, 10), + FeedId: strconv.FormatInt(row.FeedID, 10), + Guid: row.Guid, + Title: row.Title, + Url: row.Url, + IsRead: row.IsRead == 1, + Feed: ArticleFeed{ + Id: strconv.FormatInt(row.FeedID2, 10), + Url: row.FeedUrl, + Title: row.FeedTitle, + IsSubscribed: row.FeedIsSubscribed == 1, + }, + } +} + +func dbArticleToAPI(row db.GetArticleRow) Article { + return Article{ + Id: strconv.FormatInt(row.ID, 10), + FeedId: strconv.FormatInt(row.FeedID, 10), + Guid: row.Guid, + Title: row.Title, + Url: row.Url, + IsRead: row.IsRead == 1, + Feed: ArticleFeed{ + Id: strconv.FormatInt(row.FeedID2, 10), + Url: row.FeedUrl, + Title: row.FeedTitle, + }, + } +} diff --git a/backend/api/echo_context.go b/backend/api/echo_context.go new file mode 100644 index 0000000..b775850 --- /dev/null +++ b/backend/api/echo_context.go @@ -0,0 +1,21 @@ +package api + +import ( + "context" + "errors" + + "github.com/labstack/echo/v4" +) + +type echoContextKey struct{} + +var errNoEchoContext = errors.New("echo context not found") + +func getEchoContext(ctx context.Context) echo.Context { + echoCtx, _ := ctx.Value(echoContextKey{}).(echo.Context) + return echoCtx +} + +func WithEchoContext(ctx context.Context, echoCtx echo.Context) context.Context { + return context.WithValue(ctx, echoContextKey{}, echoCtx) +} diff --git a/backend/api/generated.go b/backend/api/generated.go new file mode 100644 index 0000000..4288f40 --- /dev/null +++ b/backend/api/generated.go @@ -0,0 +1,1162 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/oapi-codegen/runtime" + strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo" +) + +// AddFeedRequest defines model for AddFeedRequest. +type AddFeedRequest struct { + Url string `json:"url"` +} + +// Article defines model for Article. +type Article struct { + Feed ArticleFeed `json:"feed"` + FeedId string `json:"feedId"` + Guid string `json:"guid"` + Id string `json:"id"` + IsRead bool `json:"isRead"` + Title string `json:"title"` + Url string `json:"url"` +} + +// ArticleConnection defines model for ArticleConnection. +type ArticleConnection struct { + Articles []Article `json:"articles"` + PageInfo PageInfo `json:"pageInfo"` +} + +// ArticleFeed defines model for ArticleFeed. +type ArticleFeed struct { + Id string `json:"id"` + IsSubscribed bool `json:"isSubscribed"` + Title string `json:"title"` + Url string `json:"url"` +} + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + Message string `json:"message"` +} + +// Feed defines model for Feed. +type Feed struct { + FetchedAt string `json:"fetchedAt"` + Id string `json:"id"` + IsSubscribed bool `json:"isSubscribed"` + Title string `json:"title"` + UnreadCount int32 `json:"unreadCount"` + Url string `json:"url"` +} + +// LoginRequest defines model for LoginRequest. +type LoginRequest struct { + Password string `json:"password"` + Username string `json:"username"` +} + +// LoginResponse defines model for LoginResponse. +type LoginResponse struct { + User User `json:"user"` +} + +// PageInfo defines model for PageInfo. +type PageInfo struct { + EndCursor *string `json:"endCursor,omitempty"` + HasNextPage bool `json:"hasNextPage"` +} + +// User defines model for User. +type User struct { + Id string `json:"id"` + Username string `json:"username"` +} + +// ArticlesListReadArticlesParams defines parameters for ArticlesListReadArticles. +type ArticlesListReadArticlesParams struct { + FeedId *string `form:"feedId,omitempty" json:"feedId,omitempty"` + After *string `form:"after,omitempty" json:"after,omitempty"` + First *int32 `form:"first,omitempty" json:"first,omitempty"` +} + +// ArticlesListUnreadArticlesParams defines parameters for ArticlesListUnreadArticles. +type ArticlesListUnreadArticlesParams struct { + FeedId *string `form:"feedId,omitempty" json:"feedId,omitempty"` + After *string `form:"after,omitempty" json:"after,omitempty"` + First *int32 `form:"first,omitempty" json:"first,omitempty"` +} + +// AuthLoginJSONRequestBody defines body for AuthLogin for application/json ContentType. +type AuthLoginJSONRequestBody = LoginRequest + +// FeedsAddFeedJSONRequestBody defines body for FeedsAddFeed for application/json ContentType. +type FeedsAddFeedJSONRequestBody = AddFeedRequest + +// ServerInterface represents all server handlers. +type ServerInterface interface { + + // (GET /api/articles/read) + ArticlesListReadArticles(ctx echo.Context, params ArticlesListReadArticlesParams) error + + // (GET /api/articles/unread) + ArticlesListUnreadArticles(ctx echo.Context, params ArticlesListUnreadArticlesParams) error + + // (GET /api/articles/{articleId}) + ArticlesGetArticle(ctx echo.Context, articleId string) error + + // (POST /api/articles/{articleId}/read) + ArticlesMarkArticleRead(ctx echo.Context, articleId string) error + + // (POST /api/articles/{articleId}/unread) + ArticlesMarkArticleUnread(ctx echo.Context, articleId string) error + + // (POST /api/auth/login) + AuthLogin(ctx echo.Context) error + + // (POST /api/auth/logout) + AuthLogout(ctx echo.Context) error + + // (GET /api/auth/me) + AuthGetCurrentUser(ctx echo.Context) error + + // (GET /api/feeds) + FeedsListFeeds(ctx echo.Context) error + + // (POST /api/feeds) + FeedsAddFeed(ctx echo.Context) error + + // (DELETE /api/feeds/{feedId}) + FeedsUnsubscribeFeed(ctx echo.Context, feedId string) error + + // (GET /api/feeds/{feedId}) + FeedsGetFeed(ctx echo.Context, feedId string) error + + // (POST /api/feeds/{feedId}/read) + FeedsMarkFeedRead(ctx echo.Context, feedId string) error + + // (POST /api/feeds/{feedId}/unread) + FeedsMarkFeedUnread(ctx echo.Context, feedId string) error +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// ArticlesListReadArticles converts echo context to params. +func (w *ServerInterfaceWrapper) ArticlesListReadArticles(ctx echo.Context) error { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params ArticlesListReadArticlesParams + // ------------- Optional query parameter "feedId" ------------- + + err = runtime.BindQueryParameter("form", false, false, "feedId", ctx.QueryParams(), ¶ms.FeedId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter feedId: %s", err)) + } + + // ------------- Optional query parameter "after" ------------- + + err = runtime.BindQueryParameter("form", false, false, "after", ctx.QueryParams(), ¶ms.After) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter after: %s", err)) + } + + // ------------- Optional query parameter "first" ------------- + + err = runtime.BindQueryParameter("form", false, false, "first", ctx.QueryParams(), ¶ms.First) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter first: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ArticlesListReadArticles(ctx, params) + return err +} + +// ArticlesListUnreadArticles converts echo context to params. +func (w *ServerInterfaceWrapper) ArticlesListUnreadArticles(ctx echo.Context) error { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params ArticlesListUnreadArticlesParams + // ------------- Optional query parameter "feedId" ------------- + + err = runtime.BindQueryParameter("form", false, false, "feedId", ctx.QueryParams(), ¶ms.FeedId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter feedId: %s", err)) + } + + // ------------- Optional query parameter "after" ------------- + + err = runtime.BindQueryParameter("form", false, false, "after", ctx.QueryParams(), ¶ms.After) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter after: %s", err)) + } + + // ------------- Optional query parameter "first" ------------- + + err = runtime.BindQueryParameter("form", false, false, "first", ctx.QueryParams(), ¶ms.First) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter first: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ArticlesListUnreadArticles(ctx, params) + return err +} + +// ArticlesGetArticle converts echo context to params. +func (w *ServerInterfaceWrapper) ArticlesGetArticle(ctx echo.Context) error { + var err error + // ------------- Path parameter "articleId" ------------- + var articleId string + + err = runtime.BindStyledParameterWithOptions("simple", "articleId", ctx.Param("articleId"), &articleId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter articleId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ArticlesGetArticle(ctx, articleId) + return err +} + +// ArticlesMarkArticleRead converts echo context to params. +func (w *ServerInterfaceWrapper) ArticlesMarkArticleRead(ctx echo.Context) error { + var err error + // ------------- Path parameter "articleId" ------------- + var articleId string + + err = runtime.BindStyledParameterWithOptions("simple", "articleId", ctx.Param("articleId"), &articleId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter articleId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ArticlesMarkArticleRead(ctx, articleId) + return err +} + +// ArticlesMarkArticleUnread converts echo context to params. +func (w *ServerInterfaceWrapper) ArticlesMarkArticleUnread(ctx echo.Context) error { + var err error + // ------------- Path parameter "articleId" ------------- + var articleId string + + err = runtime.BindStyledParameterWithOptions("simple", "articleId", ctx.Param("articleId"), &articleId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter articleId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ArticlesMarkArticleUnread(ctx, articleId) + return err +} + +// AuthLogin converts echo context to params. +func (w *ServerInterfaceWrapper) AuthLogin(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.AuthLogin(ctx) + return err +} + +// AuthLogout converts echo context to params. +func (w *ServerInterfaceWrapper) AuthLogout(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.AuthLogout(ctx) + return err +} + +// AuthGetCurrentUser converts echo context to params. +func (w *ServerInterfaceWrapper) AuthGetCurrentUser(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.AuthGetCurrentUser(ctx) + return err +} + +// FeedsListFeeds converts echo context to params. +func (w *ServerInterfaceWrapper) FeedsListFeeds(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.FeedsListFeeds(ctx) + return err +} + +// FeedsAddFeed converts echo context to params. +func (w *ServerInterfaceWrapper) FeedsAddFeed(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.FeedsAddFeed(ctx) + return err +} + +// FeedsUnsubscribeFeed converts echo context to params. +func (w *ServerInterfaceWrapper) FeedsUnsubscribeFeed(ctx echo.Context) error { + var err error + // ------------- Path parameter "feedId" ------------- + var feedId string + + err = runtime.BindStyledParameterWithOptions("simple", "feedId", ctx.Param("feedId"), &feedId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter feedId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.FeedsUnsubscribeFeed(ctx, feedId) + return err +} + +// FeedsGetFeed converts echo context to params. +func (w *ServerInterfaceWrapper) FeedsGetFeed(ctx echo.Context) error { + var err error + // ------------- Path parameter "feedId" ------------- + var feedId string + + err = runtime.BindStyledParameterWithOptions("simple", "feedId", ctx.Param("feedId"), &feedId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter feedId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.FeedsGetFeed(ctx, feedId) + return err +} + +// FeedsMarkFeedRead converts echo context to params. +func (w *ServerInterfaceWrapper) FeedsMarkFeedRead(ctx echo.Context) error { + var err error + // ------------- Path parameter "feedId" ------------- + var feedId string + + err = runtime.BindStyledParameterWithOptions("simple", "feedId", ctx.Param("feedId"), &feedId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter feedId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.FeedsMarkFeedRead(ctx, feedId) + return err +} + +// FeedsMarkFeedUnread converts echo context to params. +func (w *ServerInterfaceWrapper) FeedsMarkFeedUnread(ctx echo.Context) error { + var err error + // ------------- Path parameter "feedId" ------------- + var feedId string + + err = runtime.BindStyledParameterWithOptions("simple", "feedId", ctx.Param("feedId"), &feedId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter feedId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.FeedsMarkFeedUnread(ctx, feedId) + return err +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithBaseURL(router, si, "") +} + +// Registers handlers, and prepends BaseURL to the paths, so that the paths +// can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.GET(baseURL+"/api/articles/read", wrapper.ArticlesListReadArticles) + router.GET(baseURL+"/api/articles/unread", wrapper.ArticlesListUnreadArticles) + router.GET(baseURL+"/api/articles/:articleId", wrapper.ArticlesGetArticle) + router.POST(baseURL+"/api/articles/:articleId/read", wrapper.ArticlesMarkArticleRead) + router.POST(baseURL+"/api/articles/:articleId/unread", wrapper.ArticlesMarkArticleUnread) + router.POST(baseURL+"/api/auth/login", wrapper.AuthLogin) + router.POST(baseURL+"/api/auth/logout", wrapper.AuthLogout) + router.GET(baseURL+"/api/auth/me", wrapper.AuthGetCurrentUser) + router.GET(baseURL+"/api/feeds", wrapper.FeedsListFeeds) + router.POST(baseURL+"/api/feeds", wrapper.FeedsAddFeed) + router.DELETE(baseURL+"/api/feeds/:feedId", wrapper.FeedsUnsubscribeFeed) + router.GET(baseURL+"/api/feeds/:feedId", wrapper.FeedsGetFeed) + router.POST(baseURL+"/api/feeds/:feedId/read", wrapper.FeedsMarkFeedRead) + router.POST(baseURL+"/api/feeds/:feedId/unread", wrapper.FeedsMarkFeedUnread) + +} + +type ArticlesListReadArticlesRequestObject struct { + Params ArticlesListReadArticlesParams +} + +type ArticlesListReadArticlesResponseObject interface { + VisitArticlesListReadArticlesResponse(w http.ResponseWriter) error +} + +type ArticlesListReadArticles200JSONResponse ArticleConnection + +func (response ArticlesListReadArticles200JSONResponse) VisitArticlesListReadArticlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ArticlesListUnreadArticlesRequestObject struct { + Params ArticlesListUnreadArticlesParams +} + +type ArticlesListUnreadArticlesResponseObject interface { + VisitArticlesListUnreadArticlesResponse(w http.ResponseWriter) error +} + +type ArticlesListUnreadArticles200JSONResponse ArticleConnection + +func (response ArticlesListUnreadArticles200JSONResponse) VisitArticlesListUnreadArticlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ArticlesGetArticleRequestObject struct { + ArticleId string `json:"articleId"` +} + +type ArticlesGetArticleResponseObject interface { + VisitArticlesGetArticleResponse(w http.ResponseWriter) error +} + +type ArticlesGetArticle200JSONResponse Article + +func (response ArticlesGetArticle200JSONResponse) VisitArticlesGetArticleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ArticlesGetArticle404JSONResponse ErrorResponse + +func (response ArticlesGetArticle404JSONResponse) VisitArticlesGetArticleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type ArticlesMarkArticleReadRequestObject struct { + ArticleId string `json:"articleId"` +} + +type ArticlesMarkArticleReadResponseObject interface { + VisitArticlesMarkArticleReadResponse(w http.ResponseWriter) error +} + +type ArticlesMarkArticleRead200JSONResponse Article + +func (response ArticlesMarkArticleRead200JSONResponse) VisitArticlesMarkArticleReadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ArticlesMarkArticleRead404JSONResponse ErrorResponse + +func (response ArticlesMarkArticleRead404JSONResponse) VisitArticlesMarkArticleReadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type ArticlesMarkArticleUnreadRequestObject struct { + ArticleId string `json:"articleId"` +} + +type ArticlesMarkArticleUnreadResponseObject interface { + VisitArticlesMarkArticleUnreadResponse(w http.ResponseWriter) error +} + +type ArticlesMarkArticleUnread200JSONResponse Article + +func (response ArticlesMarkArticleUnread200JSONResponse) VisitArticlesMarkArticleUnreadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ArticlesMarkArticleUnread404JSONResponse ErrorResponse + +func (response ArticlesMarkArticleUnread404JSONResponse) VisitArticlesMarkArticleUnreadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type AuthLoginRequestObject struct { + Body *AuthLoginJSONRequestBody +} + +type AuthLoginResponseObject interface { + VisitAuthLoginResponse(w http.ResponseWriter) error +} + +type AuthLogin200JSONResponse LoginResponse + +func (response AuthLogin200JSONResponse) VisitAuthLoginResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type AuthLogin401JSONResponse ErrorResponse + +func (response AuthLogin401JSONResponse) VisitAuthLoginResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type AuthLogoutRequestObject struct { +} + +type AuthLogoutResponseObject interface { + VisitAuthLogoutResponse(w http.ResponseWriter) error +} + +type AuthLogout204Response struct { +} + +func (response AuthLogout204Response) VisitAuthLogoutResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type AuthLogout401JSONResponse ErrorResponse + +func (response AuthLogout401JSONResponse) VisitAuthLogoutResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type AuthGetCurrentUserRequestObject struct { +} + +type AuthGetCurrentUserResponseObject interface { + VisitAuthGetCurrentUserResponse(w http.ResponseWriter) error +} + +type AuthGetCurrentUser200JSONResponse User + +func (response AuthGetCurrentUser200JSONResponse) VisitAuthGetCurrentUserResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type AuthGetCurrentUser401JSONResponse ErrorResponse + +func (response AuthGetCurrentUser401JSONResponse) VisitAuthGetCurrentUserResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type FeedsListFeedsRequestObject struct { +} + +type FeedsListFeedsResponseObject interface { + VisitFeedsListFeedsResponse(w http.ResponseWriter) error +} + +type FeedsListFeeds200JSONResponse []Feed + +func (response FeedsListFeeds200JSONResponse) VisitFeedsListFeedsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type FeedsAddFeedRequestObject struct { + Body *FeedsAddFeedJSONRequestBody +} + +type FeedsAddFeedResponseObject interface { + VisitFeedsAddFeedResponse(w http.ResponseWriter) error +} + +type FeedsAddFeed201JSONResponse Feed + +func (response FeedsAddFeed201JSONResponse) VisitFeedsAddFeedResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + + return json.NewEncoder(w).Encode(response) +} + +type FeedsAddFeed400JSONResponse ErrorResponse + +func (response FeedsAddFeed400JSONResponse) VisitFeedsAddFeedResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type FeedsUnsubscribeFeedRequestObject struct { + FeedId string `json:"feedId"` +} + +type FeedsUnsubscribeFeedResponseObject interface { + VisitFeedsUnsubscribeFeedResponse(w http.ResponseWriter) error +} + +type FeedsUnsubscribeFeed204Response struct { +} + +func (response FeedsUnsubscribeFeed204Response) VisitFeedsUnsubscribeFeedResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type FeedsUnsubscribeFeed404JSONResponse ErrorResponse + +func (response FeedsUnsubscribeFeed404JSONResponse) VisitFeedsUnsubscribeFeedResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type FeedsGetFeedRequestObject struct { + FeedId string `json:"feedId"` +} + +type FeedsGetFeedResponseObject interface { + VisitFeedsGetFeedResponse(w http.ResponseWriter) error +} + +type FeedsGetFeed200JSONResponse Feed + +func (response FeedsGetFeed200JSONResponse) VisitFeedsGetFeedResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type FeedsGetFeed404JSONResponse ErrorResponse + +func (response FeedsGetFeed404JSONResponse) VisitFeedsGetFeedResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type FeedsMarkFeedReadRequestObject struct { + FeedId string `json:"feedId"` +} + +type FeedsMarkFeedReadResponseObject interface { + VisitFeedsMarkFeedReadResponse(w http.ResponseWriter) error +} + +type FeedsMarkFeedRead200JSONResponse Feed + +func (response FeedsMarkFeedRead200JSONResponse) VisitFeedsMarkFeedReadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type FeedsMarkFeedRead404JSONResponse ErrorResponse + +func (response FeedsMarkFeedRead404JSONResponse) VisitFeedsMarkFeedReadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type FeedsMarkFeedUnreadRequestObject struct { + FeedId string `json:"feedId"` +} + +type FeedsMarkFeedUnreadResponseObject interface { + VisitFeedsMarkFeedUnreadResponse(w http.ResponseWriter) error +} + +type FeedsMarkFeedUnread200JSONResponse Feed + +func (response FeedsMarkFeedUnread200JSONResponse) VisitFeedsMarkFeedUnreadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type FeedsMarkFeedUnread404JSONResponse ErrorResponse + +func (response FeedsMarkFeedUnread404JSONResponse) VisitFeedsMarkFeedUnreadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + + // (GET /api/articles/read) + ArticlesListReadArticles(ctx context.Context, request ArticlesListReadArticlesRequestObject) (ArticlesListReadArticlesResponseObject, error) + + // (GET /api/articles/unread) + ArticlesListUnreadArticles(ctx context.Context, request ArticlesListUnreadArticlesRequestObject) (ArticlesListUnreadArticlesResponseObject, error) + + // (GET /api/articles/{articleId}) + ArticlesGetArticle(ctx context.Context, request ArticlesGetArticleRequestObject) (ArticlesGetArticleResponseObject, error) + + // (POST /api/articles/{articleId}/read) + ArticlesMarkArticleRead(ctx context.Context, request ArticlesMarkArticleReadRequestObject) (ArticlesMarkArticleReadResponseObject, error) + + // (POST /api/articles/{articleId}/unread) + ArticlesMarkArticleUnread(ctx context.Context, request ArticlesMarkArticleUnreadRequestObject) (ArticlesMarkArticleUnreadResponseObject, error) + + // (POST /api/auth/login) + AuthLogin(ctx context.Context, request AuthLoginRequestObject) (AuthLoginResponseObject, error) + + // (POST /api/auth/logout) + AuthLogout(ctx context.Context, request AuthLogoutRequestObject) (AuthLogoutResponseObject, error) + + // (GET /api/auth/me) + AuthGetCurrentUser(ctx context.Context, request AuthGetCurrentUserRequestObject) (AuthGetCurrentUserResponseObject, error) + + // (GET /api/feeds) + FeedsListFeeds(ctx context.Context, request FeedsListFeedsRequestObject) (FeedsListFeedsResponseObject, error) + + // (POST /api/feeds) + FeedsAddFeed(ctx context.Context, request FeedsAddFeedRequestObject) (FeedsAddFeedResponseObject, error) + + // (DELETE /api/feeds/{feedId}) + FeedsUnsubscribeFeed(ctx context.Context, request FeedsUnsubscribeFeedRequestObject) (FeedsUnsubscribeFeedResponseObject, error) + + // (GET /api/feeds/{feedId}) + FeedsGetFeed(ctx context.Context, request FeedsGetFeedRequestObject) (FeedsGetFeedResponseObject, error) + + // (POST /api/feeds/{feedId}/read) + FeedsMarkFeedRead(ctx context.Context, request FeedsMarkFeedReadRequestObject) (FeedsMarkFeedReadResponseObject, error) + + // (POST /api/feeds/{feedId}/unread) + FeedsMarkFeedUnread(ctx context.Context, request FeedsMarkFeedUnreadRequestObject) (FeedsMarkFeedUnreadResponseObject, error) +} + +type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc +type StrictMiddlewareFunc = strictecho.StrictEchoMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// ArticlesListReadArticles operation middleware +func (sh *strictHandler) ArticlesListReadArticles(ctx echo.Context, params ArticlesListReadArticlesParams) error { + var request ArticlesListReadArticlesRequestObject + + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.ArticlesListReadArticles(ctx.Request().Context(), request.(ArticlesListReadArticlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ArticlesListReadArticles") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(ArticlesListReadArticlesResponseObject); ok { + return validResponse.VisitArticlesListReadArticlesResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// ArticlesListUnreadArticles operation middleware +func (sh *strictHandler) ArticlesListUnreadArticles(ctx echo.Context, params ArticlesListUnreadArticlesParams) error { + var request ArticlesListUnreadArticlesRequestObject + + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.ArticlesListUnreadArticles(ctx.Request().Context(), request.(ArticlesListUnreadArticlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ArticlesListUnreadArticles") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(ArticlesListUnreadArticlesResponseObject); ok { + return validResponse.VisitArticlesListUnreadArticlesResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// ArticlesGetArticle operation middleware +func (sh *strictHandler) ArticlesGetArticle(ctx echo.Context, articleId string) error { + var request ArticlesGetArticleRequestObject + + request.ArticleId = articleId + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.ArticlesGetArticle(ctx.Request().Context(), request.(ArticlesGetArticleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ArticlesGetArticle") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(ArticlesGetArticleResponseObject); ok { + return validResponse.VisitArticlesGetArticleResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// ArticlesMarkArticleRead operation middleware +func (sh *strictHandler) ArticlesMarkArticleRead(ctx echo.Context, articleId string) error { + var request ArticlesMarkArticleReadRequestObject + + request.ArticleId = articleId + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.ArticlesMarkArticleRead(ctx.Request().Context(), request.(ArticlesMarkArticleReadRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ArticlesMarkArticleRead") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(ArticlesMarkArticleReadResponseObject); ok { + return validResponse.VisitArticlesMarkArticleReadResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// ArticlesMarkArticleUnread operation middleware +func (sh *strictHandler) ArticlesMarkArticleUnread(ctx echo.Context, articleId string) error { + var request ArticlesMarkArticleUnreadRequestObject + + request.ArticleId = articleId + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.ArticlesMarkArticleUnread(ctx.Request().Context(), request.(ArticlesMarkArticleUnreadRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ArticlesMarkArticleUnread") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(ArticlesMarkArticleUnreadResponseObject); ok { + return validResponse.VisitArticlesMarkArticleUnreadResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// AuthLogin operation middleware +func (sh *strictHandler) AuthLogin(ctx echo.Context) error { + var request AuthLoginRequestObject + + var body AuthLoginJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.AuthLogin(ctx.Request().Context(), request.(AuthLoginRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "AuthLogin") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(AuthLoginResponseObject); ok { + return validResponse.VisitAuthLoginResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// AuthLogout operation middleware +func (sh *strictHandler) AuthLogout(ctx echo.Context) error { + var request AuthLogoutRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.AuthLogout(ctx.Request().Context(), request.(AuthLogoutRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "AuthLogout") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(AuthLogoutResponseObject); ok { + return validResponse.VisitAuthLogoutResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// AuthGetCurrentUser operation middleware +func (sh *strictHandler) AuthGetCurrentUser(ctx echo.Context) error { + var request AuthGetCurrentUserRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.AuthGetCurrentUser(ctx.Request().Context(), request.(AuthGetCurrentUserRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "AuthGetCurrentUser") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(AuthGetCurrentUserResponseObject); ok { + return validResponse.VisitAuthGetCurrentUserResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// FeedsListFeeds operation middleware +func (sh *strictHandler) FeedsListFeeds(ctx echo.Context) error { + var request FeedsListFeedsRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.FeedsListFeeds(ctx.Request().Context(), request.(FeedsListFeedsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "FeedsListFeeds") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(FeedsListFeedsResponseObject); ok { + return validResponse.VisitFeedsListFeedsResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// FeedsAddFeed operation middleware +func (sh *strictHandler) FeedsAddFeed(ctx echo.Context) error { + var request FeedsAddFeedRequestObject + + var body FeedsAddFeedJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.FeedsAddFeed(ctx.Request().Context(), request.(FeedsAddFeedRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "FeedsAddFeed") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(FeedsAddFeedResponseObject); ok { + return validResponse.VisitFeedsAddFeedResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// FeedsUnsubscribeFeed operation middleware +func (sh *strictHandler) FeedsUnsubscribeFeed(ctx echo.Context, feedId string) error { + var request FeedsUnsubscribeFeedRequestObject + + request.FeedId = feedId + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.FeedsUnsubscribeFeed(ctx.Request().Context(), request.(FeedsUnsubscribeFeedRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "FeedsUnsubscribeFeed") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(FeedsUnsubscribeFeedResponseObject); ok { + return validResponse.VisitFeedsUnsubscribeFeedResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// FeedsGetFeed operation middleware +func (sh *strictHandler) FeedsGetFeed(ctx echo.Context, feedId string) error { + var request FeedsGetFeedRequestObject + + request.FeedId = feedId + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.FeedsGetFeed(ctx.Request().Context(), request.(FeedsGetFeedRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "FeedsGetFeed") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(FeedsGetFeedResponseObject); ok { + return validResponse.VisitFeedsGetFeedResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// FeedsMarkFeedRead operation middleware +func (sh *strictHandler) FeedsMarkFeedRead(ctx echo.Context, feedId string) error { + var request FeedsMarkFeedReadRequestObject + + request.FeedId = feedId + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.FeedsMarkFeedRead(ctx.Request().Context(), request.(FeedsMarkFeedReadRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "FeedsMarkFeedRead") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(FeedsMarkFeedReadResponseObject); ok { + return validResponse.VisitFeedsMarkFeedReadResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// FeedsMarkFeedUnread operation middleware +func (sh *strictHandler) FeedsMarkFeedUnread(ctx echo.Context, feedId string) error { + var request FeedsMarkFeedUnreadRequestObject + + request.FeedId = feedId + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.FeedsMarkFeedUnread(ctx.Request().Context(), request.(FeedsMarkFeedUnreadRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "FeedsMarkFeedUnread") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(FeedsMarkFeedUnreadResponseObject); ok { + return validResponse.VisitFeedsMarkFeedUnreadResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} diff --git a/backend/api/handler.go b/backend/api/handler.go new file mode 100644 index 0000000..c5df375 --- /dev/null +++ b/backend/api/handler.go @@ -0,0 +1,16 @@ +package api + +import ( + "database/sql" + + "undef.ninja/x/feedaka/auth" + "undef.ninja/x/feedaka/db" +) + +type Handler struct { + DB *sql.DB + Queries *db.Queries + SessionConfig *auth.SessionConfig +} + +var _ StrictServerInterface = (*Handler)(nil) diff --git a/backend/api/handler_articles.go b/backend/api/handler_articles.go new file mode 100644 index 0000000..d480ddc --- /dev/null +++ b/backend/api/handler_articles.go @@ -0,0 +1,245 @@ +package api + +import ( + "context" + "database/sql" + "fmt" + "strconv" + + appcontext "undef.ninja/x/feedaka/context" + "undef.ninja/x/feedaka/db" +) + +const defaultPageSize = 30 +const maxPageSize = 100 + +func (h *Handler) ArticlesListUnreadArticles(ctx context.Context, request ArticlesListUnreadArticlesRequestObject) (ArticlesListUnreadArticlesResponseObject, error) { + conn, err := h.paginatedArticles(ctx, 0, request.Params.FeedId, request.Params.After, request.Params.First) + if err != nil { + return nil, err + } + return ArticlesListUnreadArticles200JSONResponse(*conn), nil +} + +func (h *Handler) ArticlesListReadArticles(ctx context.Context, request ArticlesListReadArticlesRequestObject) (ArticlesListReadArticlesResponseObject, error) { + conn, err := h.paginatedArticles(ctx, 1, request.Params.FeedId, request.Params.After, request.Params.First) + if err != nil { + return nil, err + } + return ArticlesListReadArticles200JSONResponse(*conn), nil +} + +func (h *Handler) ArticlesGetArticle(ctx context.Context, request ArticlesGetArticleRequestObject) (ArticlesGetArticleResponseObject, error) { + userID, ok := appcontext.GetUserID(ctx) + if !ok { + return nil, fmt.Errorf("authentication required") + } + + articleID, err := strconv.ParseInt(request.ArticleId, 10, 64) + if err != nil { + return ArticlesGetArticle404JSONResponse{Message: "invalid article ID"}, nil + } + + row, err := h.Queries.GetArticle(ctx, articleID) + if err != nil { + if err == sql.ErrNoRows { + return ArticlesGetArticle404JSONResponse{Message: "article not found"}, nil + } + return nil, err + } + + f, err := h.Queries.GetFeed(ctx, row.FeedID) + if err != nil { + return nil, err + } + if f.UserID != userID { + return ArticlesGetArticle404JSONResponse{Message: "article not found"}, nil + } + + article := dbArticleToAPI(row) + return ArticlesGetArticle200JSONResponse(article), nil +} + +func (h *Handler) ArticlesMarkArticleRead(ctx context.Context, request ArticlesMarkArticleReadRequestObject) (ArticlesMarkArticleReadResponseObject, error) { + article, err := h.updateArticleReadStatus(ctx, request.ArticleId, 1) + if err != nil { + return nil, err + } + if article == nil { + return ArticlesMarkArticleRead404JSONResponse{Message: "article not found"}, nil + } + return ArticlesMarkArticleRead200JSONResponse(*article), nil +} + +func (h *Handler) ArticlesMarkArticleUnread(ctx context.Context, request ArticlesMarkArticleUnreadRequestObject) (ArticlesMarkArticleUnreadResponseObject, error) { + article, err := h.updateArticleReadStatus(ctx, request.ArticleId, 0) + if err != nil { + return nil, err + } + if article == nil { + return ArticlesMarkArticleUnread404JSONResponse{Message: "article not found"}, nil + } + return ArticlesMarkArticleUnread200JSONResponse(*article), nil +} + +func (h *Handler) updateArticleReadStatus(ctx context.Context, articleIdStr string, isRead int64) (*Article, error) { + userID, ok := appcontext.GetUserID(ctx) + if !ok { + return nil, fmt.Errorf("authentication required") + } + + articleID, err := strconv.ParseInt(articleIdStr, 10, 64) + if err != nil { + return nil, nil + } + + article, err := h.Queries.GetArticle(ctx, articleID) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + + f, err := h.Queries.GetFeed(ctx, article.FeedID) + if err != nil { + return nil, err + } + if f.UserID != userID { + return nil, nil + } + + err = h.Queries.UpdateArticleReadStatus(ctx, db.UpdateArticleReadStatusParams{ + IsRead: isRead, + ID: article.ID, + }) + if err != nil { + return nil, err + } + + // Re-fetch for updated state + updated, err := h.Queries.GetArticle(ctx, articleID) + if err != nil { + return nil, err + } + + result := dbArticleToAPI(updated) + return &result, nil +} + +func (h *Handler) paginatedArticles(ctx context.Context, isRead int64, feedID *string, after *string, first *int32) (*ArticleConnection, error) { + userID, ok := appcontext.GetUserID(ctx) + if !ok { + return nil, fmt.Errorf("authentication required") + } + + limit := int64(defaultPageSize) + if first != nil { + limit = int64(*first) + if limit <= 0 { + limit = int64(defaultPageSize) + } + if limit > maxPageSize { + limit = maxPageSize + } + } + + fetchLimit := limit + 1 + + var rawRows []any + + if feedID != nil { + parsedFeedID, err := strconv.ParseInt(*feedID, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid feed ID: %w", err) + } + + if after != nil { + cursor, err := strconv.ParseInt(*after, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid cursor: %w", err) + } + rows, err := h.Queries.GetArticlesByFeedPaginatedAfter(ctx, db.GetArticlesByFeedPaginatedAfterParams{ + IsRead: isRead, + UserID: userID, + FeedID: parsedFeedID, + ID: cursor, + Limit: fetchLimit, + }) + if err != nil { + return nil, err + } + for _, row := range rows { + rawRows = append(rawRows, row) + } + } else { + rows, err := h.Queries.GetArticlesByFeedPaginated(ctx, db.GetArticlesByFeedPaginatedParams{ + IsRead: isRead, + UserID: userID, + FeedID: parsedFeedID, + Limit: fetchLimit, + }) + if err != nil { + return nil, err + } + for _, row := range rows { + rawRows = append(rawRows, row) + } + } + } else { + if after != nil { + cursor, err := strconv.ParseInt(*after, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid cursor: %w", err) + } + rows, err := h.Queries.GetArticlesPaginatedAfter(ctx, db.GetArticlesPaginatedAfterParams{ + IsRead: isRead, + UserID: userID, + ID: cursor, + Limit: fetchLimit, + }) + if err != nil { + return nil, err + } + for _, row := range rows { + rawRows = append(rawRows, row) + } + } else { + rows, err := h.Queries.GetArticlesPaginated(ctx, db.GetArticlesPaginatedParams{ + IsRead: isRead, + UserID: userID, + Limit: fetchLimit, + }) + if err != nil { + return nil, err + } + for _, row := range rows { + rawRows = append(rawRows, row) + } + } + } + + hasNextPage := int64(len(rawRows)) > limit + if hasNextPage { + rawRows = rawRows[:limit] + } + + articles := make([]Article, 0, len(rawRows)) + for _, raw := range rawRows { + articles = append(articles, rowToArticle(toArticleRow(raw))) + } + + var endCursor *string + if len(articles) > 0 { + lastID := articles[len(articles)-1].Id + endCursor = &lastID + } + + return &ArticleConnection{ + Articles: articles, + PageInfo: PageInfo{ + HasNextPage: hasNextPage, + EndCursor: endCursor, + }, + }, nil +} diff --git a/backend/api/handler_auth.go b/backend/api/handler_auth.go new file mode 100644 index 0000000..6e10538 --- /dev/null +++ b/backend/api/handler_auth.go @@ -0,0 +1,73 @@ +package api + +import ( + "context" + "database/sql" + "strconv" + + "undef.ninja/x/feedaka/auth" + appcontext "undef.ninja/x/feedaka/context" +) + +func (h *Handler) AuthLogin(ctx context.Context, request AuthLoginRequestObject) (AuthLoginResponseObject, error) { + user, err := h.Queries.GetUserByUsername(ctx, request.Body.Username) + if err != nil { + if err == sql.ErrNoRows { + return AuthLogin401JSONResponse{Message: "invalid credentials"}, nil + } + return AuthLogin401JSONResponse{Message: "invalid credentials"}, nil + } + + if !auth.VerifyPassword(user.PasswordHash, request.Body.Password) { + return AuthLogin401JSONResponse{Message: "invalid credentials"}, nil + } + + echoCtx := getEchoContext(ctx) + if echoCtx == nil { + return nil, errNoEchoContext + } + + if err := h.SessionConfig.SetUserID(echoCtx, user.ID); err != nil { + return nil, err + } + + return AuthLogin200JSONResponse{ + User: User{ + Id: strconv.FormatInt(user.ID, 10), + Username: user.Username, + }, + }, nil +} + +func (h *Handler) AuthLogout(ctx context.Context, _ AuthLogoutRequestObject) (AuthLogoutResponseObject, error) { + echoCtx := getEchoContext(ctx) + if echoCtx == nil { + return nil, errNoEchoContext + } + + if err := h.SessionConfig.DestroySession(echoCtx); err != nil { + return nil, err + } + + return AuthLogout204Response{}, nil +} + +func (h *Handler) AuthGetCurrentUser(ctx context.Context, _ AuthGetCurrentUserRequestObject) (AuthGetCurrentUserResponseObject, error) { + userID, ok := appcontext.GetUserID(ctx) + if !ok { + return AuthGetCurrentUser401JSONResponse{Message: "authentication required"}, nil + } + + user, err := h.Queries.GetUserByID(ctx, userID) + if err != nil { + if err == sql.ErrNoRows { + return AuthGetCurrentUser401JSONResponse{Message: "authentication required"}, nil + } + return nil, err + } + + return AuthGetCurrentUser200JSONResponse{ + Id: strconv.FormatInt(user.ID, 10), + Username: user.Username, + }, nil +} diff --git a/backend/api/handler_feeds.go b/backend/api/handler_feeds.go new file mode 100644 index 0000000..f0a8785 --- /dev/null +++ b/backend/api/handler_feeds.go @@ -0,0 +1,185 @@ +package api + +import ( + "context" + "database/sql" + "fmt" + "strconv" + "time" + + appcontext "undef.ninja/x/feedaka/context" + "undef.ninja/x/feedaka/db" + "undef.ninja/x/feedaka/feed" +) + +func (h *Handler) FeedsListFeeds(ctx context.Context, _ FeedsListFeedsRequestObject) (FeedsListFeedsResponseObject, error) { + userID, ok := appcontext.GetUserID(ctx) + if !ok { + return nil, fmt.Errorf("authentication required") + } + + dbFeeds, err := h.Queries.GetFeeds(ctx, userID) + if err != nil { + return nil, err + } + + unreadCounts, err := h.Queries.GetFeedUnreadCounts(ctx, userID) + if err != nil { + return nil, err + } + countMap := make(map[int64]int64, len(unreadCounts)) + for _, uc := range unreadCounts { + countMap[uc.FeedID] = uc.UnreadCount + } + + feeds := make(FeedsListFeeds200JSONResponse, 0, len(dbFeeds)) + for _, f := range dbFeeds { + feeds = append(feeds, dbFeedToAPI(f, countMap[f.ID])) + } + + return feeds, nil +} + +func (h *Handler) FeedsAddFeed(ctx context.Context, request FeedsAddFeedRequestObject) (FeedsAddFeedResponseObject, error) { + userID, ok := appcontext.GetUserID(ctx) + if !ok { + return nil, fmt.Errorf("authentication required") + } + + f, err := feed.Fetch(ctx, request.Body.Url) + if err != nil { + return FeedsAddFeed400JSONResponse{Message: fmt.Sprintf("failed to parse feed: %v", err)}, nil + } + + dbFeed, err := h.Queries.CreateFeed(ctx, db.CreateFeedParams{ + Url: request.Body.Url, + Title: f.Title, + FetchedAt: time.Now().UTC().Format(time.RFC3339), + UserID: userID, + }) + if err != nil { + return FeedsAddFeed400JSONResponse{Message: fmt.Sprintf("failed to insert feed: %v", err)}, nil + } + + if err := feed.Sync(ctx, h.Queries, dbFeed.ID, f); err != nil { + return FeedsAddFeed400JSONResponse{Message: fmt.Sprintf("failed to sync articles: %v", err)}, nil + } + + return FeedsAddFeed201JSONResponse(dbFeedToAPI(dbFeed, 0)), nil +} + +func (h *Handler) FeedsGetFeed(ctx context.Context, request FeedsGetFeedRequestObject) (FeedsGetFeedResponseObject, error) { + userID, ok := appcontext.GetUserID(ctx) + if !ok { + return nil, fmt.Errorf("authentication required") + } + + feedID, err := strconv.ParseInt(request.FeedId, 10, 64) + if err != nil { + return FeedsGetFeed404JSONResponse{Message: "invalid feed ID"}, nil + } + + dbFeed, err := h.Queries.GetFeed(ctx, feedID) + if err != nil { + if err == sql.ErrNoRows { + return FeedsGetFeed404JSONResponse{Message: "feed not found"}, nil + } + return nil, err + } + + if dbFeed.UserID != userID { + return FeedsGetFeed404JSONResponse{Message: "feed not found"}, nil + } + + return FeedsGetFeed200JSONResponse(dbFeedToAPI(dbFeed, 0)), nil +} + +func (h *Handler) FeedsUnsubscribeFeed(ctx context.Context, request FeedsUnsubscribeFeedRequestObject) (FeedsUnsubscribeFeedResponseObject, error) { + userID, ok := appcontext.GetUserID(ctx) + if !ok { + return nil, fmt.Errorf("authentication required") + } + + feedID, err := strconv.ParseInt(request.FeedId, 10, 64) + if err != nil { + return FeedsUnsubscribeFeed404JSONResponse{Message: "invalid feed ID"}, nil + } + + dbFeed, err := h.Queries.GetFeed(ctx, feedID) + if err != nil { + if err == sql.ErrNoRows { + return FeedsUnsubscribeFeed404JSONResponse{Message: "feed not found"}, nil + } + return nil, err + } + + if dbFeed.UserID != userID { + return FeedsUnsubscribeFeed404JSONResponse{Message: "feed not found"}, nil + } + + if err := h.Queries.UnsubscribeFeed(ctx, feedID); err != nil { + return nil, err + } + + return FeedsUnsubscribeFeed204Response{}, nil +} + +func (h *Handler) FeedsMarkFeedRead(ctx context.Context, request FeedsMarkFeedReadRequestObject) (FeedsMarkFeedReadResponseObject, error) { + userID, ok := appcontext.GetUserID(ctx) + if !ok { + return nil, fmt.Errorf("authentication required") + } + + feedID, err := strconv.ParseInt(request.FeedId, 10, 64) + if err != nil { + return FeedsMarkFeedRead404JSONResponse{Message: "invalid feed ID"}, nil + } + + dbFeed, err := h.Queries.GetFeed(ctx, feedID) + if err != nil { + if err == sql.ErrNoRows { + return FeedsMarkFeedRead404JSONResponse{Message: "feed not found"}, nil + } + return nil, err + } + + if dbFeed.UserID != userID { + return FeedsMarkFeedRead404JSONResponse{Message: "feed not found"}, nil + } + + if err := h.Queries.MarkFeedArticlesRead(ctx, feedID); err != nil { + return nil, err + } + + return FeedsMarkFeedRead200JSONResponse(dbFeedToAPI(dbFeed, 0)), nil +} + +func (h *Handler) FeedsMarkFeedUnread(ctx context.Context, request FeedsMarkFeedUnreadRequestObject) (FeedsMarkFeedUnreadResponseObject, error) { + userID, ok := appcontext.GetUserID(ctx) + if !ok { + return nil, fmt.Errorf("authentication required") + } + + feedID, err := strconv.ParseInt(request.FeedId, 10, 64) + if err != nil { + return FeedsMarkFeedUnread404JSONResponse{Message: "invalid feed ID"}, nil + } + + dbFeed, err := h.Queries.GetFeed(ctx, feedID) + if err != nil { + if err == sql.ErrNoRows { + return FeedsMarkFeedUnread404JSONResponse{Message: "feed not found"}, nil + } + return nil, err + } + + if dbFeed.UserID != userID { + return FeedsMarkFeedUnread404JSONResponse{Message: "feed not found"}, nil + } + + if err := h.Queries.MarkFeedArticlesUnread(ctx, feedID); err != nil { + return nil, err + } + + return FeedsMarkFeedUnread200JSONResponse(dbFeedToAPI(dbFeed, 0)), nil +} diff --git a/backend/api/oapi-codegen.yaml b/backend/api/oapi-codegen.yaml new file mode 100644 index 0000000..bb775d9 --- /dev/null +++ b/backend/api/oapi-codegen.yaml @@ -0,0 +1,6 @@ +package: api +output: generated.go +generate: + echo-server: true + models: true + strict-server: true -- cgit v1.3-1-g0d28