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 ( | ||||
| 	"context" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"git.sleepycat.moe/sam/mercury/internal/database" | ||||
| 	"git.sleepycat.moe/sam/mercury/web/api" | ||||
| 	"github.com/go-chi/render" | ||||
| ) | ||||
| 
 | ||||
| type ctxKey int | ||||
|  | @ -42,6 +45,62 @@ func (app *App) FrontendAuth(next http.Handler) http.Handler { | |||
| 	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) { | ||||
| 	v := ctx.Value(ctxKeyClaims) | ||||
| 	if v == nil { | ||||
|  |  | |||
|  | @ -1,6 +1,9 @@ | |||
| package web | ||||
| 
 | ||||
| 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/auth" | ||||
| 	"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}/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…
	
	Add table
		Add a link
		
	
		Reference in a new issue