add API boilerplate + /accounts/{accountID} and /accounts/@me endpoints
This commit is contained in:
parent
0fa769a248
commit
dfc116d828
7 changed files with 335 additions and 0 deletions
48
web/api/accounts/get_account.go
Normal file
48
web/api/accounts/get_account.go
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
package accounts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.sleepycat.moe/sam/mercury/internal/database/sql"
|
||||||
|
"git.sleepycat.moe/sam/mercury/web/api"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) GetID(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
accountID, err := ulid.Parse(chi.URLParam(r, "accountID"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, api.Error{Code: api.ErrAccountNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
acct, err := app.Account().ByID(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNotFound {
|
||||||
|
return nil, api.Error{Code: api.ErrAccountNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Err(err).Str("id", accountID.String()).Msg("fetching user from database")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, ok := app.TokenFromContext(ctx)
|
||||||
|
if ok && token.UserID == acct.ID {
|
||||||
|
return api.DBAccountToSelfAccount(acct), nil
|
||||||
|
}
|
||||||
|
return api.DBAccountToAccount(acct), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) GetMe(w http.ResponseWriter, r *http.Request) (api.SelfAccount, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
token, _ := app.TokenFromContext(ctx) // Token will always be available
|
||||||
|
|
||||||
|
acct, err := app.Account().ByID(ctx, token.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Str("id", token.UserID.String()).Msg("fetching user from database")
|
||||||
|
return api.SelfAccount{}, err
|
||||||
|
}
|
||||||
|
return api.DBAccountToSelfAccount(acct), nil
|
||||||
|
}
|
13
web/api/accounts/module.go
Normal file
13
web/api/accounts/module.go
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
package accounts
|
||||||
|
|
||||||
|
import "git.sleepycat.moe/sam/mercury/web/app"
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
*app.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(app *app.App) *App {
|
||||||
|
return &App{
|
||||||
|
App: app,
|
||||||
|
}
|
||||||
|
}
|
36
web/api/blog.go
Normal file
36
web/api/blog.go
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sleepycat.moe/sam/mercury/internal/database"
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Blog is the basic blog returned by endpoints.
|
||||||
|
type Blog struct {
|
||||||
|
ID ulid.ULID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Domain *string `json:"domain"`
|
||||||
|
Bio string `json:"bio"`
|
||||||
|
|
||||||
|
Account blogPartialAccount `json:"account"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type blogPartialAccount struct {
|
||||||
|
ID ulid.ULID `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Domain *string `json:"domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DBBlogToBlog(b database.Blog, a database.Account) Blog {
|
||||||
|
return Blog{
|
||||||
|
ID: b.ID,
|
||||||
|
Name: b.Name,
|
||||||
|
Domain: b.Domain,
|
||||||
|
Bio: b.Bio,
|
||||||
|
Account: blogPartialAccount{
|
||||||
|
ID: a.ID,
|
||||||
|
Username: a.Username,
|
||||||
|
Domain: a.Domain,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
166
web/api/error.go
Normal file
166
web/api/error.go
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NoValue should be returned by functions that either:
|
||||||
|
// - don't return any content for one or more code paths
|
||||||
|
// - handle rendering content themselves
|
||||||
|
const NoValue = errors.Sentinel("no content")
|
||||||
|
|
||||||
|
// WrapHandlerT wraps a modified http.HandlerFunc into a stdlib-compatible one.
|
||||||
|
// The inner HandlerFunc additionally returns a JSON response and an error.
|
||||||
|
func WrapHandlerT[T any](hn func(w http.ResponseWriter, r *http.Request) (T, error)) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v, err := hn(w, r)
|
||||||
|
if err == nil {
|
||||||
|
render.JSON(w, r, v)
|
||||||
|
return
|
||||||
|
} else if err == NoValue {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the function returned an API error, just render that verbatim
|
||||||
|
if apiErr, ok := err.(Error); ok {
|
||||||
|
apiErr.prepare()
|
||||||
|
|
||||||
|
render.Status(r, apiErr.Status)
|
||||||
|
render.JSON(w, r, apiErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise, we return an internal server error message
|
||||||
|
apiErr := Error{Code: ErrInternalServerError}
|
||||||
|
apiErr.prepare()
|
||||||
|
|
||||||
|
render.Status(r, apiErr.Status)
|
||||||
|
render.JSON(w, r, apiErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapHandler wraps a modified http.HandlerFunc into a stdlib-compatible one.
|
||||||
|
// The inner HandlerFunc additionally returns an error.
|
||||||
|
func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
err := hn(w, r)
|
||||||
|
if err == nil {
|
||||||
|
return nil, NoValue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return WrapHandlerT(fn)
|
||||||
|
|
||||||
|
// return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// err := hn(w, r)
|
||||||
|
// if err != nil {
|
||||||
|
// // if the function returned an API error, just render that verbatim
|
||||||
|
// // we can assume that it also logged the error (if that was needed)
|
||||||
|
// if apiErr, ok := err.(Error); ok {
|
||||||
|
// apiErr.prepare()
|
||||||
|
|
||||||
|
// render.Status(r, apiErr.Status)
|
||||||
|
// render.JSON(w, r, apiErr)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // otherwise, we log the error and return an internal server error message
|
||||||
|
// log.Err(err).Msg("error in http handler")
|
||||||
|
|
||||||
|
// apiErr := Error{Code: ErrInternalServerError}
|
||||||
|
// apiErr.prepare()
|
||||||
|
|
||||||
|
// render.Status(r, apiErr.Status)
|
||||||
|
// render.JSON(w, r, apiErr)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
|
||||||
|
// Status is set as the HTTP status code.
|
||||||
|
Status int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Error) Error() string {
|
||||||
|
if e.Message == "" {
|
||||||
|
e.Message = errCodeMessages[e.Code]
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Details != "" {
|
||||||
|
return fmt.Sprintf("%s (code: %d) (%s)", e.Message, e.Code, e.Details)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s (code: %d)", e.Message, e.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) prepare() {
|
||||||
|
if e.Status == 0 {
|
||||||
|
e.Status = errCodeStatuses[e.Code]
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Message == "" {
|
||||||
|
e.Message = errCodeMessages[e.Code]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error code constants
|
||||||
|
const (
|
||||||
|
ErrBadRequest = 400
|
||||||
|
ErrForbidden = 403
|
||||||
|
ErrNotFound = 404
|
||||||
|
ErrMethodNotAllowed = 405
|
||||||
|
ErrTooManyRequests = 429
|
||||||
|
ErrInternalServerError = 500 // catch-all code for unknown errors
|
||||||
|
|
||||||
|
// Auth related
|
||||||
|
ErrInvalidToken = 1001
|
||||||
|
ErrMissingScope = 1002
|
||||||
|
|
||||||
|
// Account related
|
||||||
|
ErrAccountNotFound = 1003
|
||||||
|
)
|
||||||
|
|
||||||
|
func ErrCodeMessage(code int) string {
|
||||||
|
return errCodeMessages[code]
|
||||||
|
}
|
||||||
|
|
||||||
|
var errCodeMessages = map[int]string{
|
||||||
|
ErrBadRequest: "Bad request",
|
||||||
|
ErrForbidden: "Forbidden",
|
||||||
|
ErrInternalServerError: "Internal server error",
|
||||||
|
ErrNotFound: "Not found",
|
||||||
|
ErrTooManyRequests: "Rate limit reached",
|
||||||
|
ErrMethodNotAllowed: "Method not allowed",
|
||||||
|
|
||||||
|
ErrInvalidToken: "No token supplied, or token is invalid",
|
||||||
|
ErrMissingScope: "Token is missing required scope for this endpoint",
|
||||||
|
|
||||||
|
ErrAccountNotFound: "Account not found",
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrCodeStatus(code int) int {
|
||||||
|
return errCodeStatuses[code]
|
||||||
|
}
|
||||||
|
|
||||||
|
var errCodeStatuses = map[int]int{
|
||||||
|
ErrBadRequest: http.StatusBadRequest,
|
||||||
|
ErrForbidden: http.StatusForbidden,
|
||||||
|
ErrInternalServerError: http.StatusInternalServerError,
|
||||||
|
ErrNotFound: http.StatusNotFound,
|
||||||
|
ErrTooManyRequests: http.StatusTooManyRequests,
|
||||||
|
ErrMethodNotAllowed: http.StatusMethodNotAllowed,
|
||||||
|
|
||||||
|
ErrInvalidToken: http.StatusUnauthorized,
|
||||||
|
ErrMissingScope: http.StatusForbidden,
|
||||||
|
|
||||||
|
ErrAccountNotFound: http.StatusNotFound,
|
||||||
|
}
|
|
@ -3,9 +3,12 @@ package app
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sleepycat.moe/sam/mercury/internal/database"
|
"git.sleepycat.moe/sam/mercury/internal/database"
|
||||||
|
"git.sleepycat.moe/sam/mercury/web/api"
|
||||||
|
"github.com/go-chi/render"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ctxKey int
|
type ctxKey int
|
||||||
|
@ -42,6 +45,62 @@ func (app *App) FrontendAuth(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(fn)
|
return http.HandlerFunc(fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (app *App) APIAuth(scope database.TokenScope, anonAccess bool) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
header := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if header == "" {
|
||||||
|
cookie, err := r.Cookie(database.TokenCookieName)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
if anonAccess { // no token supplied, but the endpoint allows anonymous access
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
render.Status(r, api.ErrCodeStatus(api.ErrInvalidToken))
|
||||||
|
render.JSON(w, r, api.Error{
|
||||||
|
Code: api.ErrInvalidToken,
|
||||||
|
Message: api.ErrCodeMessage(api.ErrInvalidToken),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := app.ParseToken(r.Context(), header)
|
||||||
|
if err != nil {
|
||||||
|
render.Status(r, api.ErrCodeStatus(api.ErrInvalidToken))
|
||||||
|
render.JSON(w, r, api.Error{
|
||||||
|
Code: api.ErrInvalidToken,
|
||||||
|
Message: api.ErrCodeMessage(api.ErrInvalidToken),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if token.Expires.Before(time.Now()) {
|
||||||
|
render.Status(r, api.ErrCodeStatus(api.ErrInvalidToken))
|
||||||
|
render.JSON(w, r, api.Error{
|
||||||
|
Code: api.ErrInvalidToken,
|
||||||
|
Message: api.ErrCodeMessage(api.ErrInvalidToken),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.Scopes.Has(scope) {
|
||||||
|
render.Status(r, api.ErrCodeStatus(api.ErrMissingScope))
|
||||||
|
render.JSON(w, r, api.Error{
|
||||||
|
Code: api.ErrMissingScope,
|
||||||
|
Message: api.ErrCodeMessage(api.ErrMissingScope),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), ctxKeyClaims, token)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (app *App) TokenFromContext(ctx context.Context) (database.Token, bool) {
|
func (app *App) TokenFromContext(ctx context.Context) (database.Token, bool) {
|
||||||
v := ctx.Value(ctxKeyClaims)
|
v := ctx.Value(ctxKeyClaims)
|
||||||
if v == nil {
|
if v == nil {
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"git.sleepycat.moe/sam/mercury/internal/database"
|
||||||
|
"git.sleepycat.moe/sam/mercury/web/api"
|
||||||
|
"git.sleepycat.moe/sam/mercury/web/api/accounts"
|
||||||
"git.sleepycat.moe/sam/mercury/web/app"
|
"git.sleepycat.moe/sam/mercury/web/app"
|
||||||
"git.sleepycat.moe/sam/mercury/web/auth"
|
"git.sleepycat.moe/sam/mercury/web/auth"
|
||||||
"git.sleepycat.moe/sam/mercury/web/frontend"
|
"git.sleepycat.moe/sam/mercury/web/frontend"
|
||||||
|
@ -31,4 +34,14 @@ func Routes(app *app.App) {
|
||||||
r.HandleFunc("/web/@{username}", frontend.ServeUser)
|
r.HandleFunc("/web/@{username}", frontend.ServeUser)
|
||||||
r.HandleFunc("/web/@{username}/posts/{postID}", frontend.ServeStatus)
|
r.HandleFunc("/web/@{username}/posts/{postID}", frontend.ServeStatus)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// APIv1 handlers
|
||||||
|
app.Router.Route("/api/v1", func(r chi.Router) {
|
||||||
|
// account handlers
|
||||||
|
accounts := accounts.New(app)
|
||||||
|
r.With(app.APIAuth(database.TokenScopeAccountsRead, true)).
|
||||||
|
Get("/accounts/{accountID}", api.WrapHandlerT(accounts.GetID))
|
||||||
|
r.With(app.APIAuth(database.TokenScopeAccountsMe, false)).
|
||||||
|
Get("/accounts/@me", api.WrapHandlerT(accounts.GetMe))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue