webfmwk

package module
v2.5.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jan 23, 2020 License: MIT Imports: 21 Imported by: 0

README

Build Status codecov GoDoc GolangCI Go Report Card CodeFactor CII Best Practices License

What

webfmwk is an internal framework build and own by Frafos GmbH.

It was designed to be a minimalist go web framework supporting JSON API.

The purpose of the framework is to use as few external library than possible.

The server handle ctrl+c on it's own.

TODO: explain that perf is not the purpose. that's why panic are used - more user friendly.

dep

what for
gorilla/mux for a easy & robust routing logic
gorilla/hanlers for some useful already coded middlewares
gorilla/schema for some useful already coded middlewares
validator use by the custom implementation of the context
json-iterator use by the custom implementation of the context

Test

Simply run go test .

How to use it

Their are a few mains in the ./exmaple directory. The content of the mains or used later in the README.md.

Example

Hello world !

Reach the endpoint with curl -X GET 'http://localhost:4242/hello'.

hello world

package main

import (
    "net/http"

    w "github.com/burgesQ/webfmwk/v2"
)

func main() {
    // create server
    s := w.InitServer(true)

    s.GET("/hello", func(c w.IContext) {
        c.JSONBlob(http.StatusOK, []byte(`{ "message": "hello world" }`))
    })

    // start asynchronously on :4242
    go func() {
        s.Start(":4242")
    }()

    // ctrl+c is handled internally
    defer s.WaitAndStop()
}

fetch query param

Reach the endpoint with curl -X GET 'http://localhost:4242/hello?&pjson&turlu=tutu'.

query param

package main

import (
    "net/http"

    w "github.com/burgesQ/webfmwk/v2"
    "github.com/burgesQ/webfmwk/v2/log"
)

func main() {
    // create server
    s := w.InitServer(true)

    s.GET("/hello", func(c w.IContext) {
        var (
            queries   = c.GetQueries()
            pjson, ok = c.GetQuery("pjson")
        )
        if ok {
            log.Errorf("%#v", pjson)
        }
        c.JSON(http.StatusOK, queries)
    })

    // start asynchronously on :4242
    go func() {
        s.Start(":4242")
    }()

    // ctrl+c is handled internally
    defer s.WaitAndStop()
}

fetch url params

Reach the endpoint with curl -X GET 'http://localhost:4242/hello/you'.

url param

package main

import (
    "net/http"

    w "github.com/burgesQ/webfmwk/v2"
)

func main() {
    // create server
    s := w.InitServer(true)

    s.GET("/hello/{id}", func(c w.IContext) {
        c.JSONBlob(http.StatusOK, []byte(`{ "id": "`+c.GetVar("id")+`" }`))
    })

    // start asynchronously on :4242
    go func() {
        s.Start(":4242")
    }()

    // ctrl+c is handled internally
    defer s.WaitAndStop()
}

deserialize body / query param / validate

Reach the endpoint with curl -X POST -d '{"name": "test", "age": 12}' -H "Content-Type: application/json" "http://localhost:4242/hello".

Note that the webfmwk only accept application/json content (for the moment ?).

Don't hesitate to play with the payload to inspect the behavior of the Validate method.

The struct annotation are done via the validator and schema keywords. Please refer to the validator documentation and the gorilla/schema one.

POST content

package main

import (
    "net/http"

    w "github.com/burgesQ/webfmwk/v2"
)

type (
    // Content hold the body of the request
    Content struct {
        Name string `schema:"name" json:"name" validate:"omitempty"`
        Age  int    `schema:"age" json:"age" validate:"gte=1"`
    }

    // QueryParam hold the query params
    QueryParam struct {
        PJSON bool `schema:"pjson" json:"pjson"`
        Val   int  `schema:"val" json:"val" validate:"gte=1"`
    }

    // Payload hold the output of the endpoint
    Payload struct {
        Content Content    `json:"content"`
        QP      QueryParam `json:"query_param"`
    }
)

func main() {
    // create server
    s := w.InitServer(true)

    s.POST("/hello", func(c w.IContext) {

        out := Payload{}

        c.FetchContent(&out.content)
        c.Validate(out.content)

        c.DecodeQP(&out.qp)
        c.Validate(out.qp)

        c.JSON(http.StatusOK, out)
    })

    // start asynchronously on :4242
    go func() {
        s.Start(":4244")
    }()

    // ctrl+c is handled internally
    defer s.WaitAndStop()
}

Set a base url

Reach the endpoint with curl -X GET 'http://localhost:4242/api/v1/test and curl -X GET 'http://localhost:4242/api/v2/test.

base url

package main

import (
    "github.com/burgesQ/webfmwk/v2"
)

var (
    routes = webfmwk.RoutesPerPrefix{
        "/api/v1": {
            {
                Verbe: "GET",
                Path:  "/test",
                Name:  "test v1",
                Handler: func(c webfmwk.IContext) {
                    c.JSONOk("v1 ok")
                },
            },
        },
        "/api/v2": {
            {
                Verbe: "GET",
                Path:  "/test",
                Name:  "test v2",
                Handler: func(c webfmwk.IContext) {
                    c.JSONOk("v2 ok")
                },
            },
        },
    }
)

func main() {

    s := webfmwk.InitServer(true)

    s.RouteApplier(routes)

    // start asynchronously on :4242
    go func() {
        s.Start(":4242")
    }()

    // ctrl+c is handled internaly
    defer s.WaitAndStop()

}

Use tls

Use the method Server.StartTLS(addr, certPath, keyPath string).

use tls

package main

import (
    w "github.com/burgesQ/webfmwk/v2"
)

func main() {
    // init server w/ ctrl+c support
    s := w.InitServer(true)

    s.GET("/test", func(c w.IContext) error {
        return c.JSONOk("ok")
    })

    // start asynchronously on :4242
    go func() {
        s.StartTLS(":4242", TLSConfig{
            Cert:     "/path/to/cert",
            Key:      "/path/to/key",
            Insecure: true,
        })
    }()

    // ctrl+c is handled internally
    defer s.WaitAndStop()
}

Register a custom logger

The logger must implement the webfmwk/log.ILog interface.

custom logger

package main

import (
    w "github.com/burgesQ/webfmwk/v2"
    "github.com/burgesQ/webfmwk/v2/log"
)

// GetLogger return a log.ILog interface
var logger = log.GetLogger()

func main() {
    // init server w/ ctrl+c support
    s := w.InitServer(true)

    s.SetLogger(logger)

    s.GET("/test", func(c w.IContext) error {
        return c.JSONOk("ok")
    })

    // start asynchronously on :4242
    go func() {
        s.StartTLS(":4242", TLSConfig{
            Cert:     "/path/to/cert",
            Key:      "/path/to/key",
            Insecure: true,
        })
    }()

    // ctrl+c is handled internally
    defer s.WaitAndStop()
}

Register a extended context

Create a struct that extend webfmwk.Context.

Then, add a middleware to extend the context using the Server.SetCustomContext(func(c *Context) IContext)

extend context

package main

import (
    w "github.com/burgesQ/webfmwk/v2"
)

type customContext struct {
    w.Context
    customVal string
}

func main() {
    // init server w/ ctrl+c support
    s := w.InitServer(true)

    s.SetCustomContext(func(c *w.Context) w.IContext {
        ctx := &customContext{*c, "42"}
        return ctx
    })

    s.GET("/test", func(c w.IContext) {
        ctx := c.(*customContext)
        c.JSONOk(ctx.customVal)
    })

    // start asynchronously on :4242
    go func() {
        s.Start(":4244")
    }()

    // ctrl+c is handled internally
    defer s.WaitAndStop()
}

Register middlewares

Import github.com/burgesQ/webfmwk/v2/middleware

extend middleware

package main

import (
    w "github.com/burgesQ/webfmwk/v2"
    m "github.com/burgesQ/webfmwk/v2/middleware"
)

func main() {

    // init server w/ ctrl+c support
    s := w.InitServer(true)

    s.AddMiddleware(m.Logging)
    s.AddMiddleware(m.Security)

    s.GET("/test", func(c w.IContext) error {
        return c.JSONOk("ok")
    })

    // start asynchronously on :4242
    go func() {
        s.Start(":4242")
    }()

    // ctrl+c is handled internally
    defer s.WaitAndStop()
}

Swagger doc compatibility

Import github.com/swaggo/http-swagger.

Then, from a browser reach :4242/api/doc/index.html.

Or, run curl -X GET 'http://localhost:4242/api/doc/swagger.json'.

swagger doc

package main

import (
    w "github.com/burgesQ/webfmwk/v2"
    httpSwagger "github.com/swaggo/http-swagger"
)

type Answer struct {
    Message string `json:"message"`
}

// @Summary hello world
// @Description Return a simple greeting
// @Param pjson query bool false "return a pretty JSON"
// @Success 200 {object} db.Reply
// @Produce application/json
// @Router /hello [get]
func hello(c w.IContext) error {
    return c.JSONOk(Answer{"ok"})
}

// @title hello world API
// @version 1.0
// @description This is an simple API
// @termsOfService https://www.youtube.com/watch?v=DLzxrzFCyOs
// @contact.name Quentin Burgess
// @contact.url github.com/burgesQ
// @contact.email quentin@frafos.com
// @license.name GFO
// @host localhost:4242
func main() {
    // init server w/ ctrl+c support
    s := w.InitServer(true)

    s.SetPrefix("/api")

    s.RegisterDocHandler(httpSwagger.WrapHandler)

    s.GET("/test", func(c w.IContext) error {
        return c.JSONOk("ok")
    })

    // start asynchronously on :4242
    go func() {
        s.Start(":4242")
    }()

    // ctrl+c is handled internally
    defer s.WaitAndStop()
}

Add worker

Use the Server.GetWorkerLauncher() method.

Run the test main and wait 10 sec.

extra worker

package main

import (
    "time"

    w "github.com/burgesQ/webfmwk/v2"
    "github.com/burgesQ/webfmwk/v2/log"
)

func main() {

    log.SetLogLevel(log.LogDEBUG)

    // init server w/ ctrl+c support
    s := w.InitServer(true)
    wl := s.GetLauncher()

    s.GET("/test", func(c w.IContext) {
        c.JSONOk("ok")
    })

    wl.Start("custom worker", func() error {
        time.Sleep(10 * time.Second)
        log.Debugf("done")
        return nil
    })

    // start asynchronously on :4242
    go func() {
        s.Start(":4242")
    }()

    // ctrl+c is handled internally
    defer s.WaitAndStop()
}

Documentation

Overview

Package webfmwk implements an minimalist Go web framework

Example:

package main

import (
  w "github.com/burgesQ/webfmwk/v2"
)
// Handler
func hello(c w.IContext) error {
  return c.JSONOk("Hello, World!")
}

func main() {
  // Echo instance
  s := w.InitServer(true)

  // Routes
  s.GET("/hello", hello)

  // start server on :4242
  go func() {
    s.Start(":4242")
  }()

  // ctrl+c is handled internaly
  defer s.WaitAndStop()
}

Learn more at https://github.com/burgesQ/webfmwk

Index

Constants

View Source
const (
	GET    = "GET"
	POST   = "POST"
	PATCH  = "PATCH"
	PUT    = "PUT"
	DELETE = "DELETE"
)

Variables

This section is empty.

Functions

func GetLogger added in v2.2.0

func GetLogger() log.ILog

GetLogger return an instance of the ILog interface used

func Shutdown

func Shutdown(ctx context.Context)

Shutdown terminate all running servers. Call shutdown with a context.context on each http(s) server.

Types

type AnonymousError

type AnonymousError struct {
	Error string `json:"error"`
}

AnonymousError struct is used to answer error

type Context

type Context struct {
	// contains filtered or unexported fields
}

Context implement the IContext interface It hold the data used by the request

func (Context) CheckHeader

func (c Context) CheckHeader()

CheckHeader implement IContext

func (Context) DecodeQP added in v2.3.6

func (c Context) DecodeQP(dest interface{})

DecodeQP implement IContext

func (*Context) FetchContent

func (c *Context) FetchContent(dest interface{})

FetchContent implement IContext It load payload in the dest interface{} using the system json library

func (*Context) GetQueries

func (c *Context) GetQueries() map[string][]string

GetQueries implement IContext

func (*Context) GetQuery

func (c *Context) GetQuery(key string) (string, bool)

GetQuery implement IContext

func (Context) GetVar

func (c Context) GetVar(key string) string

GetVar implement IContext

func (Context) IsPretty

func (c Context) IsPretty() bool

IsPretty implement IContext

func (*Context) JSON

func (c *Context) JSON(statusCode int, content interface{})

JSON create a JSON response based on the param content.

func (*Context) JSONAccepted added in v2.5.2

func (c *Context) JSONAccepted(content interface{})

JSONAccepted implement IContext

func (*Context) JSONBadRequest

func (c *Context) JSONBadRequest(content interface{})

JSONBadRequest implement IContext

func (*Context) JSONBlob

func (c *Context) JSONBlob(statusCode int, content []byte)

JSONBlob sent a JSON response already encoded

func (*Context) JSONConflict

func (c *Context) JSONConflict(content interface{})

JSONConflict implement IContext

func (*Context) JSONCreated

func (c *Context) JSONCreated(content interface{})

JSONCreated implement IContext

func (*Context) JSONInternalError

func (c *Context) JSONInternalError(content interface{})

JSONInternalError implement IContext

func (*Context) JSONNoContent

func (c *Context) JSONNoContent()

JSONNoContent implement IContext

func (*Context) JSONNotFound

func (c *Context) JSONNotFound(content interface{})

JSONNotFound implement IContext

func (*Context) JSONNotImplemented

func (c *Context) JSONNotImplemented(content interface{})

JSONNotImplemented implement IContext

func (*Context) JSONOk

func (c *Context) JSONOk(content interface{})

JSONOk implement IContext

func (*Context) JSONUnprocessable

func (c *Context) JSONUnprocessable(content interface{})

JSONUnprocessable implement IContext

func (Context) OwnRecover

func (c Context) OwnRecover()

OwnRecover implement IContext

func (*Context) SendResponse

func (c *Context) SendResponse(statusCode int, content []byte, headers ...[2]string)

Send Response implement IContext

func (*Context) SetLogger

func (c *Context) SetLogger(logger log.ILog)

func (*Context) SetQuery

func (c *Context) SetQuery(q map[string][]string)

SetQuery implement IContext

func (*Context) SetRequest

func (c *Context) SetRequest(r *http.Request)

SetRequest implement IContext

func (*Context) SetVars

func (c *Context) SetVars(v map[string]string)

SetVars implement IContext

func (*Context) SetWriter

func (c *Context) SetWriter(w *http.ResponseWriter)

SetWriter implement IContext

func (Context) Validate

func (c Context) Validate(dest interface{})

Validate implement IContext this implemt use validator to anotate & check struct

type ErrorHandled

type ErrorHandled struct {
	// contains filtered or unexported fields
}

ErrorHandled implement the IErrorHandled interface

func NewBadRequest

func NewBadRequest(content interface{}) ErrorHandled

NewBadRequest produce an ErrorHandled with the status code 400

func NewConflict added in v2.4.2

func NewConflict(content interface{}) ErrorHandled

NewConflict produce an ErrorHandled with the status code 501

func NewErrorHandled added in v2.3.7

func NewErrorHandled(op int, content interface{}) ErrorHandled

NewError return a new ErrorHandled var

func NewInternal

func NewInternal(content interface{}) ErrorHandled

NewUnprocessable produce an ErrorHandled with the status code 500

func NewNoContent

func NewNoContent() ErrorHandled

NewNoContent produce an ErrorHandled with the status code 204

func NewNotAcceptable

func NewNotAcceptable(content interface{}) ErrorHandled

NewNotAcceptable produce an ErrorHandled with the status code 406

func NewNotFound

func NewNotFound(content interface{}) ErrorHandled

NewNotAcceptable produce an ErrorHandled with the status code 404

func NewNotImplemented added in v2.3.0

func NewNotImplemented(content interface{}) ErrorHandled

NewUnprocessable produce an ErrorHandled with the status code 501

func NewProcessing added in v2.3.7

func NewProcessing(content interface{}) ErrorHandled

func NewUnauthorized added in v2.3.7

func NewUnauthorized(content interface{}) ErrorHandled

func NewUnprocessable

func NewUnprocessable(content interface{}) ErrorHandled

NewUnprocessable produce an ErrorHandled with the status code 422

func (ErrorHandled) Error added in v2.3.7

func (e ErrorHandled) Error() string

func (ErrorHandled) GetContent

func (e ErrorHandled) GetContent() interface{}

GetContent implement the IErrorHandled interface

func (ErrorHandled) GetOPCode

func (e ErrorHandled) GetOPCode() int

GetOPCode implement the IErrorHandled interface

type HandlerSign

type HandlerSign func(c IContext)

HandlerSign hold the signature of the controller

type IContext

type IContext interface {

	// SetRequest is used to save the request object
	SetRequest(rq *http.Request)

	// SetWriter is used to save the ResponseWriter obj
	// TODO: use io.Writer ?
	SetWriter(rw *http.ResponseWriter)

	// FetchContent extract the content from the body
	FetchContent(content interface{})

	// Validate is used to validate a content of the content params
	Validate(content interface{})

	// Decode load the query param in the content object
	DecodeQP(content interface{})

	// SetVars is used to save the url vars
	SetVars(vars map[string]string)

	// GetVar return the url var parameters. Empty string for none
	GetVar(key string) (val string)

	// SetQuery save the query param object
	SetQuery(query map[string][]string)

	// GetQueries return the queries object
	GetQueries() map[string][]string

	// GetQuery fetch the query object key
	GetQuery(key string) (val string, ok bool)

	// IsPretty toggle the compact outptu mode
	IsPretty() bool

	// SetLogger set the logger of the ctx
	SetLogger(logger log.ILog)

	// CheckHeader ensure the Content-Type of the request
	CheckHeader()

	// OwnRecover is used to encapsulate the wanted panic
	OwnRecover()

	// SendResponse create & send a response according to the parameters
	SendResponse(op int, content []byte, headers ...[2]string)

	// JSONBlob answer the JSON content with the status code op
	JSONBlob(op int, content []byte)

	// JSON answer the JSON content with the status code op
	JSON(op int, content interface{})

	//
	JSONNotImplemented(interface{})

	//
	JSONNoContent()

	//
	JSONBadRequest(interface{})

	// 201
	JSONCreated(interface{})

	//
	JSONUnprocessable(interface{})

	// 200
	JSONOk(interface{})

	// 404
	JSONNotFound(interface{})

	//
	JSONConflict(interface{})

	//
	JSONInternalError(interface{})

	//
	JSONAccepted(interface{})
}

IContext Interface implement the context used in this project

type IErrorHandled

type IErrorHandled interface {
	GetOPCode() int
	GetContent() interface{}
}

IErrorHandled interface implement the panic recovering

type Route

type Route struct {
	Verbe   string      `json:"verbe"`
	Path    string      `json:"path"`
	Name    string      `json:"name"`
	Handler HandlerSign `json:"-"`
}

Route hold the data for one route

type Routes

type Routes []Route

Routes hold an array of route

type RoutesPerPrefix added in v2.4.0

type RoutesPerPrefix map[string]Routes

RoutesPerPrefix hold the routes and there respectiv prefix

type Server

type Server struct {
	CORS bool
	// contains filtered or unexported fields
}

Server is a struct holding all the necessary data / struct

func InitServer

func InitServer(withCtrl bool) Server

InitServer set the server struct & pre-launch the exit handler. Init the worker internal launcher. If withCtrl is set to true, the server will handle ctrl+C internall.y Please add worker to the package's WorkerLauncher to sync them.

func (*Server) AddMiddleware

func (s *Server) AddMiddleware(mw mux.MiddlewareFunc)

Enamelware append a middleware to the list of middleware

func (*Server) AddRoute

func (s *Server) AddRoute(r Route)

AddRoute add a new route to expose

func (*Server) AddRoutes

func (s *Server) AddRoutes(r Routes)

AddRoutes save all the routes to expose

func (*Server) DELETE

func (s *Server) DELETE(path string, handler HandlerSign)

DELETE expose a route to the http verb DELETE

func (*Server) ExitHandler

func (s *Server) ExitHandler(ctx context.Context, sig ...os.Signal)

ExitHandler handle ctrl+c in intern

func (*Server) GET

func (s *Server) GET(path string, handler HandlerSign)

GET expose a route to the http verb GET

func (*Server) GetContext

func (s *Server) GetContext() *context.Context

GetContext return a pointer on the context.Context used

func (*Server) GetLauncher

func (s *Server) GetLauncher() *WorkerLauncher

GetLauncher return a pointer on the util.workerLauncher used

func (Server) GetLogger added in v2.3.1

func (s Server) GetLogger() log.ILog

GetLogger return an instance of the ILog interface used

func (*Server) PATCH

func (s *Server) PATCH(path string, handler HandlerSign)

PATCH expose a route to the http verb PATCH

func (*Server) POST

func (s *Server) POST(path string, handler HandlerSign)

POST expose a route to the http verb POST

func (*Server) PUT

func (s *Server) PUT(path string, handler HandlerSign)

PUT expose a route to the http verb PUT

func (*Server) RegisterDocHandler

func (s *Server) RegisterDocHandler(handler http.Handler)

RegisterDocHandler is used to save an swagger doc handler

func (*Server) RouteApplier added in v2.4.0

func (s *Server) RouteApplier(rpp RoutesPerPrefix)

RouteApplier apply the array of RoutePerPrefix

func (*Server) SetCustomContext

func (s *Server) SetCustomContext(setter func(c *Context) IContext) bool

SetCustomContext save a custom context so it can be fetched in the controller handler

func (*Server) SetLogger

func (s *Server) SetLogger(lg log.ILog)

SetLogger set the logger of the server

func (*Server) SetPrefix

func (s *Server) SetPrefix(prefix string)

SetPrefix set the url path to prefix

func (*Server) SetRouter

func (s *Server) SetRouter() *mux.Router

SetRouter create a mux.Handler router and then : register the middlewares, register the user defined routes per prefix, and return the routes handler

func (*Server) SetWorkerParams added in v2.3.3

func (s *Server) SetWorkerParams(w WorkerConfig)

SetWorkerParams merge the WorkerConfig param with the package variable workerConfig. The workerConfig is then used to spawn an http.Server

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context)

Shutdown call the framework shutdown to stop all running server

func (*Server) Start

func (s *Server) Start(addr string)

Start expose an server to an HTTP endpoint

func (*Server) StartTLS

func (s *Server) StartTLS(addr string, tlsStuffs TLSConfig)

StartTLS expose an server to an HTTPS endpoint

func (*Server) WaitAndStop

func (s *Server) WaitAndStop()

WaitAndStop wait for all servers to terminate. Use of a sync.waitGroup to properly wait all group.

type TLSConfig

type TLSConfig struct {
	Cert     string `json:"cert"`
	Key      string `json:"key"`
	Insecure bool   `json:"insecure"`
}

TLSConfig contain the tls config passed by the config file

type ValidationError added in v2.5.0

type ValidationError struct {
	Error validator.ValidationErrorsTranslations `json:"error"`
}

Context implement the IContext interface It hold the data used by the request

type WorkerConfig added in v2.3.3

type WorkerConfig struct {
	// ReadTimeout is a timing constraint on the client http request imposed by the server from the moment
	// of initial connection up to the time the entire request body has been read.
	// [Accept] --> [TLS Handshake] --> [Request Headers] --> [Request Body] --> [Response]
	ReadTimeout       time.Duration
	ReadHeaderTimeout time.Duration
	// WriteTimeout is a time limit imposed on client connecting to the server via http from the
	// time the server has completed reading the request header up to the time it has finished writing the response.
	// [Accept] --> [TLS Handshake] --> [Request Headers] --> [Request Body] --> [Response]
	WriteTimeout   time.Duration
	MaxHeaderBytes int
}

WorkerConfig hold the worker config per server instance

type WorkerLauncher

type WorkerLauncher struct {
	// contains filtered or unexported fields
}

WorkerLauncher hold the different workers

func CreateWorkerLauncher

func CreateWorkerLauncher(wg *sync.WaitGroup, cancel context.CancelFunc) WorkerLauncher

to use as a factory as the fields are unexported

func (*WorkerLauncher) Start

func (l *WorkerLauncher) Start(name string, fn func() error)

launch a worker who will be wait & kill at the same time than the others

Directories

Path Synopsis
package log implement the ILog interface used by the webfmwk
package log implement the ILog interface used by the webfmwk
Package middleware implement some basic middleware for the webfmwk middleware provides a convenient mechanism for filtering HTTP requests entering the application.
Package middleware implement some basic middleware for the webfmwk middleware provides a convenient mechanism for filtering HTTP requests entering the application.
Package testing hold some testing method used to assert the webfmwk API
Package testing hold some testing method used to assert the webfmwk API

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL