Skip to content

Commit

Permalink
restructered some of the internals and added basic js app hooks support
Browse files Browse the repository at this point in the history
  • Loading branch information
ganigeorgiev committed Jun 8, 2023
1 parent ff5508c commit 3cf3e04
Show file tree
Hide file tree
Showing 24 changed files with 1,214 additions and 418 deletions.
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,36 @@

- (@todo docs) Added `Dao.WithoutHooks()` helper to create a new `Dao` from the current one but without the create/update/delete hooks.

- **!** Renamed `*Options` to `*Config` for consistency and replaced the unnecessary pointers with their value equivalent to keep the applied configuration defaults isolated within their function calls:
```go
old: pocketbase.NewWithConfig(config *pocketbase.Config)
new: pocketbase.NewWithConfig(config pocketbase.Config)

old: core.NewBaseApp(config *core.BaseAppConfig)
new: core.NewBaseApp(config core.BaseAppConfig)

old: apis.Serve(app core.App, options *apis.ServeOptions)
new: apis.Serve(app core.App, config apis.ServeConfig)

old: jsvm.MustRegisterMigrations(app core.App, options *jsvm.MigrationsOptions)
new: jsvm.MustRegisterMigrations(app core.App, config jsvm.MigrationsConfig)

old: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, options *ghupdate.Options)
new: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, config ghupdate.Config)

old: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, options *migratecmd.Options)
new: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, config migratecmd.Config)
```

- (@todo docs) Added new optional JavaScript app hooks binding via [goja](https://github.com/dop251/goja).
There are available by default with the prebuilt executable if you add a `*.pb.js` file in `pb_hooks` directory.
To enable them as part of a custom Go build:
```go
jsvm.MustRegisterHooks(app core.App, config jsvm.HooksConfig{})
```

- Refactored `apis.ApiError` validation errors serialization to allow `map[string]error` and `map[string]any` when generating the public safe formatted `ApiError.Data`.


## v0.16.4

Expand Down
74 changes: 49 additions & 25 deletions apis/api_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,43 +66,67 @@ func NewUnauthorizedError(message string, data any) *ApiError {

// NewApiError creates and returns new normalized `ApiError` instance.
func NewApiError(status int, message string, data any) *ApiError {
message = inflector.Sentenize(message)

formattedData := map[string]any{}

if v, ok := data.(validation.Errors); ok {
formattedData = resolveValidationErrors(v)
}

return &ApiError{
rawData: data,
Data: formattedData,
Data: safeErrorsData(data),
Code: status,
Message: strings.TrimSpace(message),
Message: strings.TrimSpace(inflector.Sentenize(message)),
}
}

func resolveValidationErrors(validationErrors validation.Errors) map[string]any {
func safeErrorsData(data any) map[string]any {
switch v := data.(type) {
case validation.Errors:
return resolveSafeErrorsData[error](v)
case map[string]validation.Error:
return resolveSafeErrorsData[validation.Error](v)
case map[string]error:
return resolveSafeErrorsData[error](v)
case map[string]any:
return resolveSafeErrorsData[any](v)
default:
return map[string]any{} // not nil to ensure that is json serialized as object
}
}

func resolveSafeErrorsData[T any](data map[string]T) map[string]any {
result := map[string]any{}

// extract from each validation error its error code and message.
for name, err := range validationErrors {
// check for nested errors
if nestedErrs, ok := err.(validation.Errors); ok {
result[name] = resolveValidationErrors(nestedErrs)
for name, err := range data {
if isNestedError(err) {
result[name] = safeErrorsData(err)
continue
}
result[name] = resolveSafeErrorItem(err)
}

errCode := "validation_invalid_value" // default
if errObj, ok := err.(validation.ErrorObject); ok {
errCode = errObj.Code()
}
return result
}

result[name] = map[string]string{
"code": errCode,
"message": inflector.Sentenize(err.Error()),
}
func isNestedError(err any) bool {
switch err.(type) {
case validation.Errors, map[string]validation.Error, map[string]error, map[string]any:
return true
}

return result
return false
}

// resolveSafeErrorItem extracts from each validation error its
// public safe error code and message.
func resolveSafeErrorItem(err any) map[string]string {
// default public safe error values
code := "validation_invalid_value"
msg := "Invalid value."

// only validation errors are public safe
if obj, ok := err.(validation.Error); ok {
code = obj.Code()
msg = inflector.Sentenize(obj.Error())
}

return map[string]string{
"code": code,
"message": msg,
}
}
10 changes: 10 additions & 0 deletions apis/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"path/filepath"
"strings"

"github.com/dop251/goja"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/pocketbase/core"
Expand All @@ -34,6 +35,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
return echo.NewRouter(echo.RouterConfig{
UnescapePathParamValues: true,
AllowOverwritingRoute: true,
})
})

Expand All @@ -58,6 +60,14 @@ func InitApi(app core.App) (*echo.Echo, error) {
return
}

// manually extract the goja exception error value for
// consistency when throwing or returning errors
if jsException, ok := err.(*goja.Exception); ok {
if wrapped, ok := jsException.Value().Export().(error); ok {
err = wrapped
}
}

var apiErr *ApiError

switch v := err.(type) {
Expand Down
2 changes: 2 additions & 0 deletions apis/record_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ func RequestData(c echo.Context) *models.RequestData {
return result
}

// RecordAuthResponse generates and writes a properly formatted record
// auth response into the specified request context.
func RecordAuthResponse(
app core.App,
c echo.Context,
Expand Down
61 changes: 32 additions & 29 deletions apis/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,25 @@ import (
"golang.org/x/crypto/acme/autocert"
)

// ServeOptions defines an optional struct for apis.Serve().
type ServeOptions struct {
// ServeConfig defines a configuration struct for apis.Serve().
type ServeConfig struct {
// ShowStartBanner indicates whether to show or hide the server start console message.
ShowStartBanner bool
HttpAddr string
HttpsAddr string
AllowedOrigins []string // optional list of CORS origins (default to "*")

// HttpAddr is the HTTP server address to bind (eg. `127.0.0.1:80`).
HttpAddr string

// HttpsAddr is the HTTPS server address to bind (eg. `127.0.0.1:443`).
HttpsAddr string

// AllowedOrigins is an optional list of CORS origins (default to "*").
AllowedOrigins []string
}

// Serve starts a new app web server.
func Serve(app core.App, options *ServeOptions) error {
if options == nil {
options = &ServeOptions{}
}

if len(options.AllowedOrigins) == 0 {
options.AllowedOrigins = []string{"*"}
func Serve(app core.App, config ServeConfig) error {
if len(config.AllowedOrigins) == 0 {
config.AllowedOrigins = []string{"*"}
}

// ensure that the latest migrations are applied before starting the server
Expand All @@ -61,15 +64,15 @@ func Serve(app core.App, options *ServeOptions) error {
// configure cors
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
Skipper: middleware.DefaultSkipper,
AllowOrigins: options.AllowedOrigins,
AllowOrigins: config.AllowedOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
}))

// start http server
// ---
mainAddr := options.HttpAddr
if options.HttpsAddr != "" {
mainAddr = options.HttpsAddr
mainAddr := config.HttpAddr
if config.HttpsAddr != "" {
mainAddr = config.HttpsAddr
}

mainHost, _, _ := net.SplitHostPort(mainAddr)
Expand All @@ -80,7 +83,7 @@ func Serve(app core.App, options *ServeOptions) error {
HostPolicy: autocert.HostWhitelist(mainHost, "www."+mainHost),
}

serverConfig := &http.Server{
server := &http.Server{
TLSConfig: &tls.Config{
GetCertificate: certManager.GetCertificate,
NextProtos: []string{acme.ALPNProto},
Expand All @@ -95,16 +98,16 @@ func Serve(app core.App, options *ServeOptions) error {
serveEvent := &core.ServeEvent{
App: app,
Router: router,
Server: serverConfig,
Server: server,
CertManager: certManager,
}
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
return err
}

if options.ShowStartBanner {
if config.ShowStartBanner {
schema := "http"
if options.HttpsAddr != "" {
if config.HttpsAddr != "" {
schema = "https"
}

Expand All @@ -115,34 +118,34 @@ func Serve(app core.App, options *ServeOptions) error {
bold.Printf(
"%s Server started at %s\n",
strings.TrimSpace(date.String()),
color.CyanString("%s://%s", schema, serverConfig.Addr),
color.CyanString("%s://%s", schema, server.Addr),
)

regular := color.New()
regular.Printf("REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
regular.Printf("Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, server.Addr))
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, server.Addr))
}

// try to gracefully shutdown the server on app termination
app.OnTerminate().Add(func(e *core.TerminateEvent) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
serverConfig.Shutdown(ctx)
server.Shutdown(ctx)
return nil
})

// start HTTPS server
if options.HttpsAddr != "" {
if config.HttpsAddr != "" {
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
if options.HttpAddr != "" {
go http.ListenAndServe(options.HttpAddr, certManager.HTTPHandler(nil))
if config.HttpAddr != "" {
go http.ListenAndServe(config.HttpAddr, certManager.HTTPHandler(nil))
}

return serverConfig.ListenAndServeTLS("", "")
return server.ListenAndServeTLS("", "")
}

// OR start HTTP server
return serverConfig.ListenAndServe()
return server.ListenAndServe()
}

type migrationsConnection struct {
Expand Down
2 changes: 1 addition & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
Use: "serve",
Short: "Starts the web server (default to 127.0.0.1:8090)",
Run: func(command *cobra.Command, args []string) {
err := apis.Serve(app, &apis.ServeOptions{
err := apis.Serve(app, apis.ServeConfig{
HttpAddr: httpAddr,
HttpsAddr: httpsAddr,
ShowStartBanner: showStartBanner,
Expand Down
2 changes: 1 addition & 1 deletion core/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ type BaseAppConfig struct {
// configured with the provided arguments.
//
// To initialize the app, you need to call `app.Bootstrap()`.
func NewBaseApp(config *BaseAppConfig) *BaseApp {
func NewBaseApp(config BaseAppConfig) *BaseApp {
app := &BaseApp{
dataDir: config.DataDir,
isDebug: config.IsDebug,
Expand Down
20 changes: 17 additions & 3 deletions examples/base/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ func main() {
// Optional plugin flags:
// ---------------------------------------------------------------

var hooksDir string
app.RootCmd.PersistentFlags().StringVar(
&hooksDir,
"hooksDir",
"",
"the directory with the JS app hooks",
)

var migrationsDir string
app.RootCmd.PersistentFlags().StringVar(
&migrationsDir,
Expand Down Expand Up @@ -68,20 +76,25 @@ func main() {
// Plugins and hooks:
// ---------------------------------------------------------------

// load js pb_hooks
jsvm.MustRegisterHooks(app, jsvm.HooksConfig{
Dir: hooksDir,
})

// load js pb_migrations
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
jsvm.MustRegisterMigrations(app, jsvm.MigrationsConfig{
Dir: migrationsDir,
})

// migrate command (with js templates)
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
TemplateLang: migratecmd.TemplateLangJS,
Automigrate: automigrate,
Dir: migrationsDir,
})

// GitHub selfupdate
ghupdate.MustRegister(app, app.RootCmd, nil)
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})

app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
Expand All @@ -105,5 +118,6 @@ func defaultPublicDir() string {
// most likely ran with go run
return "./pb_public"
}

return filepath.Join(os.Args[0], "../pb_public")
}
Loading

0 comments on commit 3cf3e04

Please sign in to comment.