govice

package module
v0.0.0-...-5e69bb1 Latest Latest
Warning

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

Go to latest
Published: Oct 4, 2018 License: Apache-2.0 Imports: 19 Imported by: 0

README

Build Status Coverage Status

govice

Libraries to serve and protect your services implemented in golang.

It provides the following functionality:

  • Configuration. Read and marshal the configuration read from a JSON file into a struct, and override it with environment variables.
  • Validation. Validate your configuration and requests with JSON schemas.
  • Logging. Log in JSON format with custom context objects.
  • Middlewares. Some http.HandlerFunc middlewares (e.g. to log your requests and responses automatically).
  • Errors and alarms.

See examples directory with executable applications of these features. The service example is a combination of all the features provided by govice.

Configuration

The approach selected by govice to configure an application/service is based on a default configuration (using a JSON file) that can be override partially with environment variables. This approach is compliant with The Twelve-Factor App. It is also very convenient when working with docker containers.

The following example loads config.json file into a Config struct and overrides the values with environment variables (if registered).

package main

import (
	"fmt"
	"os"

	"github.com/Telefonica/govice"
)

type config struct {
	Address  string `json:"address" env:"ADDRESS"`
	BasePath string `json:"basePath" env:"BASE_PATH"`
	LogLevel string `json:"logLevel" env:"LOG_LEVEL"`
}

func main() {
	var cfg config
	if err := govice.GetConfig("config.json", &cfg); err != nil {
		panic("Invalid configuration.", err)
	}
	fmt.Printf("%+v\n", cfg)
}

The function func GetConfig(configFile string, cfg interface{}) error receives two parameters: a) path to the JSON configuration file (relative to the execution directory), b) reference to the configuration instance.

The configuration struct uses struct tags to map each field with a JSON element (using the tag json) and/or and environment variable (using the tag env). The env struct tag is implemented by github.com/caarlos0/env dependency.

Validation

The govice validation is based on JSON schemas with the library github.com/xeipuuv/gojsonschema. Its main goal is to avoid including this logic as part of the code. This separation of concerns makes the source code more readable and easier to maintain it.

It is recommended to validate any input to the service: a) configuration, b) requests to our service, c) responses received by our clients. Validation leads to safeness and robusness.

The following example extends the configuration example to validate the configuration:

func main() {
	var cfg Config
	if err := govice.GetConfig("config.json", &cfg); err != nil {
		os.Exit(1)
	}
	fmt.Prinft("%+v", cfg)

	validator := govice.NewValidator()
	if err := validator.LoadSchemas("schemas"); err != nil {
		panic(err)
	}
	if err := validator.ValidateConfig("config", &cfg); err != nil {
		panic(err)
	}
	fmt.Println("Configuration validated successfully")
}

func (v *Validator) LoadSchemas(schemasDir string) error loads all the JSON schemas located in the schemasDir directory. Note that this directory may be relative to the execution directory. Each JSON schemas is loaded and indexed with the file name removing the json extension. For example, a JSON schema stored as schemas/config.json is loaded with the key config.

Then it is possible to validate the configuration (stored in a struct) against a JSON schema (using as key the JSON schema filename without extension). func (v *Validator) ValidateConfig(schemaName string, cfg interface{}) error validates a configuration object and generates errors aligned to configuration.

The Validator type also provides other methods to validate requests, objects or arrays:

  • func (v *Validator) ValidateRequestBody(schemaName string, r *http.Request, o interface{}) error. Validates the request body against a JSON schema and unmarshals it into an object.
  • func (v *Validator) ValidateSafeRequestBody(schemaName string, r *http.Request, o interface{}) error. As ValidateRequestBody, it also validates the request body against a JSON schema and unmarshals it into an object, but it maintains the request body to be read afterwards. This can be useful if it is required to forward the same request via proxy.
  • func (v *Validator) ValidateObject(schemaName string, data interface{}) error. It validates an object against a JSON schema.
  • func (v *Validator) ValidateBytes(schemaName string, data []byte, o interface{}) error. It reads a byte array, validates it against a JSON schema, and unmarshall it. This method is used by both ValidateRequestBody and ValidateSafeRequestBody.

Logging

Logging writes log records to console using a JSON format to make easier that log aggregators (e.g. splunk) process them.

Logging requires to create an instance of Logger (e.g. with func NewLogger() *Logger). It is possible to set a log level: func (l *Logger) SetLevel(levelName string). Possible log levels are: DEBUG, INFO, WARN, ERROR, and FATAL.

The following fields are always written:

Field Description
time Timestamp when the log record was registered
lvl Log level: DEBUG, INFO, WARN, ERROR, and FATAL.
msg Log message

These fields can be enhanced by using log contexts. A log context includes additional fields in the log record. There are 2 different log contexts: a) context at logger instance which is set with func (l *Logger) SetLogContext(context interface{}), and b) context at log record. Log contexts are structs that are marshalled into the log record (it is required to use the json struct tags to make them be marshalled).

Each log level provides two methods: with and without log context (note that a log context at logger instance is complementary).

Level Log without context Log with context
DEBUG func (l *Logger) Debug(message string, args ...interface{}) func (l *Logger) DebugC(context interface{}, message string, args ...interface{})
INFO func (l *Logger) Info(message string, args ...interface{}) func (l *Logger) InfoC(context interface{}, message string, args ...interface{})
WARN func (l *Logger) Warn(message string, args ...interface{}) func (l *Logger) WarnC(context interface{}, message string, args ...interface{})
ERROR func (l *Logger) Error(message string, args ...interface{}) func (l *Logger) ErrorC(context interface{}, message string, args ...interface{})
FATAL func (l *Logger) Fatal(message string, args ...interface{}) func (l *Logger) FatalC(context interface{}, message string, args ...interface{})

There are several context struct defined in govice to work with HTTP:

// LogContext represents the log context for a base service.
type LogContext struct {
	TransactionID string `json:"trans,omitempty"`
	Correlator    string `json:"corr,omitempty"`
	Operation     string `json:"op,omitempty"`
	Service       string `json:"svc,omitempty"`
	Component     string `json:"comp,omitempty"`
	User          string `json:"user,omitempty"`
	Realm         string `json:"realm,omitempty"`
	Alarm         string `json:"alarm,omitempty"`
}

// ReqLogContext is a complementary LogContext to log information about the request (e.g. path).
type ReqLogContext struct {
	Method     string `json:"method,omitempty"`
	Path       string `json:"path,omitempty"`
	RemoteAddr string `json:"remoteaddr,omitempty"`
}

// RespLogContext is a complementary LogContext to log information about the response (e.g. status code).
type RespLogContext struct {
	Status   int    `json:"status,omitempty"`
	Latency  int    `json:"latency,omitempty"`
	Location string `json:"location,omitempty"`
}

The following example demonstrates how to use the govice logger and the contexts:

package main

import (
	"time"

	"github.com/Telefonica/govice"
)

type demoContext struct {
	Feature int `json:"feat,omitempty"`
}

func main() {
	// Force UTC time zone (used in time field of the log records)
	time.Local = time.UTC
	// Create the context for the logger instance
	ctxt := govice.LogContext{Service: "logger", Component: "demo"}

	logger := govice.NewLogger()
	logger.SetLogContext(ctxt)
	logger.Info("Logging without context")
	logger.Warn("Logging with %d %s", 2, "arguments")

	recordCtxt := &demoContext{Feature: 3}
	logger.InfoC(recordCtxt, "Logging with context")
}

The output of the previous command is:

{"time":"2017-11-12T23:29:55.929Z","lvl":"INFO","svc":"logger","comp":"demo","msg":"Logging without context"}
{"time":"2017-11-12T23:29:55.929Z","lvl":"WARN","svc":"logger","comp":"demo","msg":"Logging with 2 arguments"}
{"time":"2017-11-12T23:29:55.929Z","lvl":"INFO","svc":"logger","comp":"demo","feat":3,"msg":"Logging with context"}

Note that the logger context supports other data types beyond strings. This is really important to build up metrics based on logs.

There are additional utilities to dump requests and responses (in DEBUG level):

Method Description
func (l *Logger) DebugResponse(message string, r *http.Response) Dump a response
func (l *Logger) DebugRequest(message string, r *http.Request) Dump a request received by the app
func (l *Logger) DebugRequestOut(message string, r *http.Request) Dump a request generated by the app

Note that these methods already have a version with context (e.g. DebugResponseC).

Middlewares

Middleware Description
WithLogContext(ctx *LogContext) Creates a logger (stored in the context of the request) and prepares the transactionID and correlator in the log context. It is also responsible to include the HTTP header for the correlator in both request and response.
WithLog It logs the request and response
WithMethodNotAllowed(allowedMethods []string) Generates a response with the Allow header with the allowed HTTP methods.
WithNotFound Replies with a 404 error

There are some important utilities related to WithLogContext. func GetLogger(r *http.Request) *Logger returns the logger created by the WithLogContext middleware. func GetLogContext(r *http.Request) *LogContext returns the log context from the previous logger.

The following example creates a web server where every request and response is logged in the console. This is achieved by concatenating WithLogContext and WithLog middlewares.

package main

import (
	"fmt"
	"net/http"
	"time"

	"github.com/Telefonica/govice"
)

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hello world")
}

func main() {
	// Force UTC time zone (used in time field of the log records)
	time.Local = time.UTC
	// Create the context for the logger instance
	ctxt := govice.LogContext{Service: "logger", Component: "demo"}

	http.HandleFunc("/", govice.WithLogContext(&ctxt)(govice.WithLog(handler)))
	http.ListenAndServe(":8080", nil)
}

This example creates the following log records:

{"time":"2017-11-13T08:01:51.335Z","lvl":"INFO","trans":"e7fc31ab-c848-11e7-8ed5-186590e007bb","corr":"e7fc31ab-c848-11e7-8ed5-186590e007bb","svc":"logger","comp":"demo","method":"GET","path":"/","remoteaddr":"[::1]:49636","msg":"Request"}
{"time":"2017-11-13T08:01:51.335Z","lvl":"INFO","trans":"e7fc31ab-c848-11e7-8ed5-186590e007bb","corr":"e7fc31ab-c848-11e7-8ed5-186590e007bb","svc":"logger","comp":"demo","status":200,"msg":"Response"}

Note that the log context passed to WithLogContext middleware must follow the type govice.LogContext. This is required because the middleware sets the transactionID and correlator in this context.

The Pipeline simplifies the creation of a list of middlewares. The previous example would be:

package main

import (
	"fmt"
	"net/http"
	"time"

	"github.com/Telefonica/govice"
)

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hello world")
}

func main() {
	// Force UTC time zone (used in time field of the log records)
	time.Local = time.UTC
	// Create the context for the logger instance
	ctxt := govice.LogContext{Service: "logger", Component: "demo"}
	// Create the list of middlewares for the pipeline (excluding the last handler)
	mws := []func(http.HandlerFunc) http.HandlerFunc{
		govice.WithLogContext(&ctxt),
		govice.WithLog,
	}
	http.HandleFunc("/", govice.Pipeline(mws, handler))
	http.ListenAndServe(":8080", nil)
}

Errors and alarms

This library defines some custom errors. Errors store information for logging, and to generate the HTTP response.

type Error struct {
	Message     string `json:"-"`
	Alarm       string `json:"-"`
	Status      int    `json:"-"`
	Code        string `json:"error"`
	Description string `json:"error_description,omitempty"`
}
Field Description
Message Message to be logged
Alarm It identifies an optional alarm identifier to be included in the log record if the error requires to trigger an alarm for ops.
Status Status code of the HTTP response
Code Error identifier (or error type). It corresponds to the error field in the JSON response body
Description Description of the error (optional). It corresponds to the error_description field in the JSON response body

The format of the response body complies with the error format defined by OAuth2 standard.

The function func ReplyWithError(w http.ResponseWriter, r *http.Request, err error) has two responsibilities:

  • It generates a HTTP response using a standard error. If the error is of type govice.Error, then it is casted to retrieve all the information; otherwise, it replies with a server error.
  • It also logs the error using the logger in the request context. Note that it depends on the WithLogContext middleware. If the status code associated to the error is 4xx, then it is logged with INFO level; otherwise, with ERROR level. If the error contains an alarm identifier, it is also logged.

Additional utilities

It provides a simple utility to create a HTTP JSON response by following 2 steps:

  • Add the Content-Type header to application/json
  • Marshal a golang type to JSON. If the marshalling fails, it replies with a govice error.
func handler(w http.ResponseWriter, r *http.Request) {
	// Object to be serialized to JSON in the HTTP response
	resp := govice.LogContext{Service: "logger", Component: "demo"}
	govice.WriteJSON(w, r, &resp)
}

License

Copyright 2017 Telefónica Investigación y Desarrollo, S.A.U

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Documentation

Index

Constants

View Source
const RFC3339Milli = "2006-01-02T15:04:05.000Z07:00"

RFC3339Milli date layout

Variables

View Source
var (
	CorrelatorHTTPHeader = "Unica-Correlator"
	RequestLogMessage    = "Request"
	ResponseLogMessage   = "Response"
)

CorrelatorHTTPHeader contains the name of the HTTP header that transports the correlator. The correlator enables to match all the HTTP requests and responses for a same web flow.

View Source
var LogLevelNames = []string{"DEBUG", "INFO", "WARN", "ERROR", "FATAL"}

LogLevelNames is an array with the valid log levels.

View Source
var LoggerContextKey = loggerContextKey("logger")

LoggerContextKey is a unique key to store the logger in the golang context.

View Source
var NotFoundError = &Error{
	Message:     "not found",
	Status:      http.StatusNotFound,
	Code:        "invalid_request",
	Description: "not found",
}

NotFoundError with a not found Error

Functions

func GetConfig

func GetConfig(configFile string, config interface{}) error

GetConfig prepares the configuration by merging multiple sources: - Default configuration stored in a json file - Environment variables

func NewStdLogger

func NewStdLogger(l *Logger) *log.Logger

NewStdLogger returns a standard logger struct but using our custom logger.

func NewStdLoggerC

func NewStdLoggerC(context interface{}) *log.Logger

NewStdLoggerC returns a standard logger struct but using our custom logger with a specific context.

func NewType

func NewType(orig interface{}) interface{}

NewType creates a new object with the same type using reflection. Note that the new object is empty.

func Pipeline

func Pipeline(mws []func(http.HandlerFunc) http.HandlerFunc, endpoint http.HandlerFunc) http.HandlerFunc

Pipeline returns a HandlerFunc resulting of a list of middlewares and a final endpoint.

The following example creates a pipeline of 2 middlewares:

mws := []func(http.HandlerFunc) http.HandlerFunc{
	govice.WithLogContext(&logContext),
	govice.WithLog,
}
p := govice.Pipeline(mws, next)

func ReplyWithError

func ReplyWithError(w http.ResponseWriter, r *http.Request, err error)

ReplyWithError to send a HTTP response with the error document.

func SetDefaultLogLevel

func SetDefaultLogLevel(level string)

SetDefaultLogLevel sets the default log level. This default can be overridden with SetLevel method.

func WithLog

func WithLog(next http.HandlerFunc) http.HandlerFunc

WithLog is a middleware to log the request and response. Note that WithContext middleware is required to initialize the logger with a context.

func WithLogContext

func WithLogContext(ctxt Context) func(http.HandlerFunc) http.HandlerFunc

WithLogContext is a middleware constructor to initialize the log context with the transactionID and correlator. It also stores the logger in the golang context. Note that the context is initialized with an initial context (see ctxt).

func WithMethodNotAllowed

func WithMethodNotAllowed(allowedMethods ...string) http.HandlerFunc

WithMethodNotAllowed is a middleware to reply with an error when the HTTP method is not supported. The allowedMethods must be a list of HTTP methods.

func WithNotFound

func WithNotFound() http.HandlerFunc

WithNotFound is a middleware to reply with a not found error (status code: 404).

func WriteJSON

func WriteJSON(w http.ResponseWriter, r *http.Request, v interface{})

WriteJSON generates a HTTP response by marshalling an object to JSON. If the marshalling fails, it generates a govice error. It also sets the HTTP header "Content-Type" to "application/json".

Types

type Context

type Context interface {
	Clone() Context
	GetCorrelator() string
	SetCorrelator(corr string)
	GetTransactionID() string
	SetTransactionID(trans string)
}

Context to support extending the log context with other parameters.

func InitContext

func InitContext(r *http.Request, ctxt Context) Context

InitContext clones the context (to avoid reusing the same context attributes from previous requests) and initializes the transactionId and correlator.

type Error

type Error struct {
	Message     string `json:"-"`
	Status      int    `json:"-"`
	Alarm       string `json:"-"`
	Code        string `json:"error"`
	Description string `json:"error_description,omitempty"`
}

Error is a custom error. This struct stores information to generate an HTTP error response if required.

func NewBadGatewayError

func NewBadGatewayError(message string) *Error

NewBadGatewayError to create a bad gateway error.

func NewInvalidRequestError

func NewInvalidRequestError(message string, description string) *Error

NewInvalidRequestError to create an invalid_request Error

func NewServerError

func NewServerError(message string) *Error

NewServerError to create a server_error Error

func NewUnauthorizedClientError

func NewUnauthorizedClientError(message string, description string) *Error

NewUnauthorizedClientError to create an unauthorized_client Error

func (*Error) Error

func (e *Error) Error() string

func (*Error) GetResponse

func (e *Error) GetResponse() *http.Response

GetResponse to get a http.Response object from an Error.

func (*Error) Response

func (e *Error) Response(w http.ResponseWriter)

Response generates a JSON document for an Error. JSON is in the form: {"error": "invalid_request", "error_description": "xxx"}

type LogContext

type LogContext struct {
	TransactionID string `json:"trans,omitempty"`
	Correlator    string `json:"corr,omitempty"`
	Operation     string `json:"op,omitempty"`
	Service       string `json:"svc,omitempty"`
	Component     string `json:"comp,omitempty"`
	User          string `json:"user,omitempty"`
	Realm         string `json:"realm,omitempty"`
	Alarm         string `json:"alarm,omitempty"`
}

LogContext represents the log context for a base service. Note that LogContext implements the Context interface.

func GetLogContext

func GetLogContext(r *http.Request) *LogContext

GetLogContext gets the log context associated to a request.

func (*LogContext) Clone

func (c *LogContext) Clone() Context

Clone the log context.

func (*LogContext) GetCorrelator

func (c *LogContext) GetCorrelator() string

GetCorrelator returns the log context correlator.

func (*LogContext) GetTransactionID

func (c *LogContext) GetTransactionID() string

GetTransactionID returns the log context transactionID (trans).

func (*LogContext) SetCorrelator

func (c *LogContext) SetCorrelator(corr string)

SetCorrelator to set a correlator in the log context.

func (*LogContext) SetTransactionID

func (c *LogContext) SetTransactionID(trans string)

SetTransactionID to set a transactionID in the log context.

type LoggableResponseWriter

type LoggableResponseWriter struct {
	Status int
	http.ResponseWriter
}

LoggableResponseWriter is a ResponseWriter wrapper to log the response status code.

func (*LoggableResponseWriter) WriteHeader

func (w *LoggableResponseWriter) WriteHeader(statusCode int)

WriteHeader overwrites ResponseWriter's WriteHeader to store the response status code.

type Logger

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

Logger type.

func GetLogger

func GetLogger(r *http.Request) *Logger

GetLogger to get the logger from the request context.

func NewLogger

func NewLogger() *Logger

NewLogger to create a Logger.

func (*Logger) Debug

func (l *Logger) Debug(message string, args ...interface{})

Debug to log a message at debug level

func (*Logger) DebugC

func (l *Logger) DebugC(context interface{}, message string, args ...interface{})

DebugC to log a message at debug level with custom context

func (*Logger) DebugRequest

func (l *Logger) DebugRequest(message string, r *http.Request)

DebugRequest to dump the request at debug level.

func (*Logger) DebugRequestC

func (l *Logger) DebugRequestC(context interface{}, message string, r *http.Request)

DebugRequestC to dump the request at debug level.

func (*Logger) DebugRequestOut

func (l *Logger) DebugRequestOut(message string, r *http.Request)

DebugRequestOut to dump the output request at debug level.

func (*Logger) DebugRequestOutC

func (l *Logger) DebugRequestOutC(context interface{}, message string, r *http.Request)

DebugRequestOutC to dump the output request at debug level.

func (*Logger) DebugResponse

func (l *Logger) DebugResponse(message string, r *http.Response)

DebugResponse to dump the response at debug level.

func (*Logger) DebugResponseC

func (l *Logger) DebugResponseC(context interface{}, message string, r *http.Response)

DebugResponseC to dump the response at debug level.

func (*Logger) Error

func (l *Logger) Error(message string, args ...interface{})

Error to log a message at error level

func (*Logger) ErrorC

func (l *Logger) ErrorC(context interface{}, message string, args ...interface{})

ErrorC to log a message at error level

func (*Logger) Fatal

func (l *Logger) Fatal(message string, args ...interface{})

Fatal to log a message at fatal level

func (*Logger) FatalC

func (l *Logger) FatalC(context interface{}, message string, args ...interface{})

FatalC to log a message at fatal level

func (*Logger) GetLevel

func (l *Logger) GetLevel() string

GetLevel to return the log level.

func (*Logger) GetLogContext

func (l *Logger) GetLogContext() interface{}

GetLogContext to get the global context.

func (*Logger) GetWriter

func (l *Logger) GetWriter() io.Writer

GetWriter to get the log writer

func (*Logger) Info

func (l *Logger) Info(message string, args ...interface{})

Info to log a message at info level

func (*Logger) InfoC

func (l *Logger) InfoC(context interface{}, message string, args ...interface{})

InfoC to log a message at info level

func (*Logger) SetLevel

func (l *Logger) SetLevel(levelName string)

SetLevel to set the log level.

func (*Logger) SetLogContext

func (l *Logger) SetLogContext(context interface{})

SetLogContext to set a global context.

func (*Logger) SetWriter

func (l *Logger) SetWriter(o io.Writer)

SetWriter to set the log writer

func (*Logger) Warn

func (l *Logger) Warn(message string, args ...interface{})

Warn to log a message at warn level

func (*Logger) WarnC

func (l *Logger) WarnC(context interface{}, message string, args ...interface{})

WarnC to log a message at warn level

type ReqLogContext

type ReqLogContext struct {
	Method     string `json:"method,omitempty"`
	Path       string `json:"path,omitempty"`
	RemoteAddr string `json:"remoteaddr,omitempty"`
}

ReqLogContext is a complementary LogContext to log information about the request (e.g. path).

type RespLogContext

type RespLogContext struct {
	Status   int    `json:"status,omitempty"`
	Latency  int    `json:"latency,omitempty"`
	Location string `json:"location,omitempty"`
}

RespLogContext is a complementary LogContext to log information about the response (e.g. status code).

type Validator

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

Validator type.

func NewValidator

func NewValidator() *Validator

NewValidator is the constructor for Validator.

func (*Validator) LoadSchemas

func (v *Validator) LoadSchemas(schemasDir string) error

LoadSchemas to load all the JSON schemas stored in schemasDir directory (it may be an absolute path or relative to the current working directory)

func (*Validator) ValidateBytes

func (v *Validator) ValidateBytes(schemaName string, data []byte, o interface{}) error

ValidateBytes reads a byte array, validates it against a JSON schema, and unmarshall it.

func (*Validator) ValidateConfig

func (v *Validator) ValidateConfig(schemaName string, config interface{}) error

ValidateConfig to validate the configuration against config.json schema.

func (*Validator) ValidateObject

func (v *Validator) ValidateObject(schemaName string, data interface{}) error

ValidateObject to validate an object against a JSON schema.

func (*Validator) ValidateRequestBody

func (v *Validator) ValidateRequestBody(schemaName string, r *http.Request, o interface{}) error

ValidateRequestBody reads the request body, validates it against a JSON schema, and unmarshall it.

func (*Validator) ValidateSafeRequestBody

func (v *Validator) ValidateSafeRequestBody(schemaName string, r *http.Request, o interface{}) error

ValidateSafeRequestBody reads the request body, validates it against a JSON schema, and unmarshall it. Unlike ValidateRequestBody, it maintains the body in the request object (e.g. to be forwarded via proxy).

Directories

Path Synopsis
examples

Jump to

Keyboard shortcuts

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