amqprpc

package module
v0.1.3 Latest Latest
Warning

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

Go to latest
Published: Dec 13, 2018 License: MIT Imports: 13 Imported by: 0

README

RabbitMQ RPC

GoDoc Build Status Go Report Card Maintainability Test Coverage golangci

Description

This is a framework to use RabbitMQ with Go amqp as RPC client/server setup. The purpose of this framework is to implement a fully functional message queue setup where a user can just plug in handlers on the server(s) and use the client to communicate between them. This is suitable for many micro service architectures.

We assume that the user has some knowledge about RabbitMQ and preferrably the Go package since a few of the types are exposed to the end user. However, for simple setups there's no need for understanding of any of this.

Components

This framework consists of a client and a server with related beloning components such as request, responses, connections and other handy types.

Server

The server is designed to be a plug-and-play component with handlers attached to endpoints for amqp messages. All you need to do to start consuming messages published to routing_key looks like this:

s := NewServer("amqp://guest:guest@localhost:5672")

s.Bind(DirectBinding("routing_key", func(c context.Context, rw *ResponseWriter d *amqp.Delivery) {
    // Print what the body and header was
    fmt.Println(d.Body, d.Headers)

    // Add a response to the client
    fmt.Fprint(rw, "Handled")
}))

s.ListenAndServe()

This example will use the default exchange for direct bindings (direct) and use the routing key provided as queue name. It's also possible to specify other kind of exchanges such as topic or fanout by using the HandlerBinding type. This package already supports direct, fanout, topic and header.

s := NewServer("amqp://guest:guest@localhost:5672")

s.Bind(DirectBinding("routing_key", handleFunc))
s.Bind(FanoutBinding("fanout-exchange", handleFunc))
s.Bind(TopicBinding("queue-name", "routing_key.#", handleFunc))
s.Bind(HeadersBinding("queue-name", amqp.Table{"x-match": "all", "foo": "bar"}, handleFunc))

customBinding := HandlerBinding{
    QueueName:    "oh-sweet-queue",
    ExchangeName: "my-exchange",
    ExchangeType: "direct",
    RoutingKey:   "my-key",
    BindHeaders:  amqp.Table{},
    Handler:      handleFunc,
}

s.Bind(customBinding)
Server middlewares

Middlewares can be hooked to both a specific handler and to the entire server to be executed on all request no matter what endpoint. You can also chain middlewares to execute them in a specific order or execute multiple ones for specific use cases.

Inspired by the http, the middleware is defined as a function that takes a handler function as input and returns an identical handler function.

type ServerMiddlewareFunc func(next HandlerFunc) Handlerfunc

To execute the inner handler, call next with the correct arguments which is a context, a response writer and an amqp.Delivery:

func myMiddle(next HandlerFunc) HandlerFunc {
    // Preinitialization of middleware here.

    return func(ctx context.Context, rw *ResponseWriter d amqp.Delivery) {
        // Before handler execution here.

        // Execute the handler.
        next(ctx, rw, d)

        // After execution here.
    }
}

s := NewServer("amqp://guest:guest@localhost:5672")

// Add a middleware to specific handler.
s.Bind(DirectBinding("foobar", myMiddle(HandlerFunc)))

// Add multiple middlewares to specific handler.
s.Bind(
    DirectBinding(
        "foobar",
        ServerMiddlewareChain(
            myHandler,
            middlewareOne,
            middlewareTwo,
            middlewareThree,
        ),
    )
)

// Add middleware to all handlers on the server.
s.AddMiddleware(myMiddle)

s.ListenAndServe()
Client

The clien is designed to look similar to the server in usage and be just as easy to configure for your likings. One feature of the client is that it's build around channels where all messages are mapped to unique correlation IDs. This means that the server is non blocking and can handle multiple requests at once.

c := NewClient("amqp://guest:guest@localhost:5672")

request := NewRequest().WithRoutingKey("my_endpoint").WithBody("My body").WithResponse(true)
response, err := c.Send(request)
if err != nil {
    logger.Warn("Something went wrong", err)
}

logger.Info(string(response.Body))

The client will not connect upon calling the constructor, instead this is made the first time a connection is required, usually when calling Send. By doing this you're able to chain multiple methods after calling new to modify the client settings.

c := NewClient("amqp://guest:guest@localhost:5672").
    WithTimeout(5000 * time.Milliseconds).
    WithDialConfig(dialConfig).
    WithTLS(cert).
    WithQueueDeclareSettings(qdSettings).
    WithConsumeSettings(cSettings).
    WithHeaders(amqp.Table{})

// Will not connect until this call.
c.Send(NewRequest().WithRoutingKey("queue_one"))
Sender

The client comes with a default implementation which is a complete send function that connects to the message bus, publishes the message and if desired waits for a reply. However, the sender is just anotehr SendFunc which can be overridden. This is great for testing or in other ways mocking without the need implement an interface. This library even comes with a test package which can return a client of this type!

customSendFunc := func(r *Request) (*amqp.Delivery, error) {
    fmt.Println("Will not connect or send")

    return &amqp.Delivery{}, nil
}

c := amqprpctest.NewTestClient(customSendFunc)
c.Send(NewRequest())
Request

To perform requests easily the client expects a Request type as input when sending messages. This type holds all the information about which exchange, headers, body, content type, routing key, timeout and if a reply is desired. If a setting can be configured on both the client and the request (i.e. timeout or middlewares), the configuration on the request has a higher priority.

This is an example of how to send a fanout request without waiting for responses.

c := NewClient("amqp://guest:guest@localhost:5672")
r := NewRequest().WithExchange("fanout-exchange").WithResponse(false)

_, err := c.Send(r)

Just like the server and client, your options is chainable.

r := NewRequest().
    WithBody(`{"hello":"world"}`).
    WithContentType("application/json").
    WithContext(context.Background()).
    WithExchange("custom.exchange").
    WithHeaders(amqp.Headers{}).
    WithResponse(true).
    WithRoutingKey("routing-key").
    WithCorrelationID("my-unique-id").
    WithTimeout(5 * time.Second)

Or use the request as an io.Writer(), like the ResponseWriter.

r := NewRequest()

encoder := json.NewEncoder(r)
encoder.Encode(serializableObject)

Note: If you request a response when sending to a fanout exchange the response will be the first one respondend from any of the subscribers. There is currently no way to accept multiple responses or responses in a specific order.

Client middlewares

Just like the server this framework is implementing support to be able to easily plug code before and after a request is being sent with the client.

The middleware is defined as a function that takes a send function and returns a send function. The client itself implements the root SendFunc that generates the request and publishes it.

type SendFunc func(r *Request) (*amqp.Delivery, error)

The *amqp.Delivery is the response from the server and potential errors will be returned as well.

Just like the server you can choose to chain your custom methods to one or just add them one by one with the add interface.

func MySendMiddleware (next amqprpc.SendFunc) amqprpc.SendFunc {
    return func(r *amqprpc.Request) (*amqp.Delivery, error) {
        r.Publishing.Headers["foo"] = "bar"
        r.Publishing.AppId = "my-app"

        return next(r)
    }
}

c := NewClient(url).AddMiddleware(MySendMiddleware)

The client can also take middlewares for single requests with the exact same interface.

c := NewClient(url).AddMiddleware(MySendMiddleware)
r := NewRequest().WithRoutingKey("some.where").AddMiddleware(MyOtherMiddleware)

c.Send(r)

Since the request is more specific it's middlewares are executed after the clients middlewares. This is so the request can override headers etc.

Se examples/middleware for more examples.

Logger

You can specifiy an optional logger for amqp errors, unexpected behaviour etc. By default only error logging is turned on and is logged via the log package's standard logging.

But you can profide your own logging function for both error and debug on both the Client and the Server.

debugLogger := log.New(os.Stdout, "DEBUG - ", log.LstdFlags)
errorLogger := log.New(os.Stdout, "ERROR - ", log.LstdFlags)

server := NewServer(url)
server.WithErrorLogger(errorLogger.Printf)
server.WithDebugLogger(debugLogger.Printf)

client := NewClient(url)
client.WithErrorLogger(errorLogger.Printf)
client.WithDebugLogger(debugLogger.Printf)

This is perfect when for example using Logrus logger:

logger := logrus.New()
logger.SetLevel(logrus.DebugLevel)

server := NewServer(url)
server.WithErrorLogger(logger.Warnf)
server.WithDebugLogger(logger.Debugf)

client := NewClient(url)
client.WithErrorLogger(logger.Errorf)
client.WithDebugLogger(logger.Debugf)
Connection and TLS

As a part of the mantra to minimize implementation and handling of the actual conections this framework implements a really easy way to use TLS for either the server or the client bu just providing the path to CA, cert and key files. Under the hood this part only loads the key pair and adds the TLS configuration to the amqp configuration field.

cert := Certificates{
    Cert: "/path/to/cert.pem",
    Key:  "/path/to/key.pem",
    CA:   "/path/to/cacert.pem",
}

// Now we can pass this to the server or client and connect with TLS.
uri := "amqps://guest:guest@localhost:5671"
dialConfig := amqp.Config{
    TLSClientConfig: cert.TLSConfig(),
}

s := NewServer(uri).WithDialConfig(dialConfig)
c := NewClient(uri).WithDialConfig(dialConfig)

s.ListenAndServe()

Examples

There are a few examples included in the examples folder. For more information about how to customize your setup, see the documentation (linked above).

Documentation

Index

Constants

View Source
const (
	// CtxQueueName can be used to get the queue name from the context.Context
	// inside the HandlerFunc.
	CtxQueueName ctxKey = "queue_name"
)

Variables

View Source
var (
	// ErrUnexpectedConnClosed is returned by ListenAndServe() if the server
	// shuts down without calling Stop() and if AMQP does not give an error
	// when said shutdown happens.
	ErrUnexpectedConnClosed = errors.New("unexpected connection close without specific error")

	// ErrTimeout is an error returned when a client request does not
	// receive a response within the client timeout duration.
	ErrTimeout = errors.New("request timed out")
)

Functions

func DefaultDialer

func DefaultDialer(network, addr string) (net.Conn, error)

DefaultDialer is the RPC server default implementation of a dialer.

Types

type Certificates

type Certificates struct {
	Cert string
	Key  string
	CA   string
}

Certificates represents the certificate, the key and the CA to use when using RabbitMQ with TLS or the certificate and key when using as TLS configuration for RPC server. The fields should be the path to files stored on disk and will be passed to ioutil.ReadFile and tls.LoadX509KeyPair.

func (*Certificates) TLSConfig

func (c *Certificates) TLSConfig() *tls.Config

TLSConfig will return a *tls.Config type based on the files set in the Certificates type.

type Client

type Client struct {

	// Sender is the main send function called after all middlewares has been
	// chained and called. This field can be overridden to simplify testing.
	Sender SendFunc
	// contains filtered or unexported fields
}

Client represents an AMQP client used within a RPC framework. This client can be used to communicate with RPC servers.

func NewClient

func NewClient(url string, qc QosConfig) *Client

NewClient will return a pointer to a new Client. There are two ways to manage the connection that will be used by the client (i.e. when using TLS).

The first one is to use the Certificates type and just pass the filenames to the client certificate, key and the server CA. If this is done the function will handle the reading of the files.

It is also possible to create a custom amqp.Config with whatever configuration desired and that will be used as dial configuration when connection to the message bus.

func (*Client) AddMiddleware

func (c *Client) AddMiddleware(m ClientMiddlewareFunc) *Client

AddMiddleware will add a middleware which will be executed on request.

func (*Client) Send

func (c *Client) Send(r *Request) (*amqp.Delivery, error)

Send will send a Request by using a amqp.Publishing.

func (*Client) Stop

func (c *Client) Stop()

Stop will gracefully disconnect from AMQP after draining first incoming then outgoing messages. This method won't wait for server shutdown to complete, you should instead wait for ListenAndServe to exit.

func (*Client) WithConsumeSettings

func (c *Client) WithConsumeSettings(s ConsumeSettings) *Client

WithConsumeSettings will set the settings used when consuming in the client globally.

func (*Client) WithDebugLogger

func (c *Client) WithDebugLogger(f LogFunc) *Client

WithDebugLogger sets the logger to use for debug logging.

func (*Client) WithDialConfig

func (c *Client) WithDialConfig(dc amqp.Config) *Client

WithDialConfig sets the dial config used for the client.

func (*Client) WithErrorLogger

func (c *Client) WithErrorLogger(f LogFunc) *Client

WithErrorLogger sets the logger to use for error logging.

func (*Client) WithQueueDeclareSettings

func (c *Client) WithQueueDeclareSettings(s QueueDeclareSettings) *Client

WithQueueDeclareSettings will set the settings used when declaring queues for the client globally.

func (*Client) WithTLS

func (c *Client) WithTLS(cert Certificates) *Client

WithTLS sets the TLS config in the dial config for the client.

func (*Client) WithTimeout

func (c *Client) WithTimeout(t time.Duration) *Client

WithTimeout will set the client timeout used when publishing messages. t will be rounded using the duration's Round function to the nearest multiple of a millisecond. Rounding will be away from zero.

type ClientMiddlewareFunc

type ClientMiddlewareFunc func(next SendFunc) SendFunc

ClientMiddlewareFunc represents a function that can be used as a middleware.

type ConsumeSettings

type ConsumeSettings struct {
	Consumer  string
	AutoAck   bool
	Exclusive bool
	NoLocal   bool
	NoWait    bool
	Args      amqp.Table
}

ConsumeSettings is the settings that will be used when the consumption on a specified queue is started.

type Dialer

type Dialer func(string, string) (net.Conn, error)

Dialer is a function returning a connection used to connect to the message bus.

type ExchangeDeclareSettings

type ExchangeDeclareSettings struct {
	Durable    bool
	AutoDelete bool
	Internal   bool
	NoWait     bool
	Args       amqp.Table
}

ExchangeDeclareSettings is the settings that will be used when a handler is mapped to a fanout exchange and an exchange is declared.

type HandlerBinding

type HandlerBinding struct {
	QueueName    string
	ExchangeName string
	ExchangeType string
	RoutingKey   string
	BindHeaders  amqp.Table
	Handler      HandlerFunc
}

HandlerBinding holds information about how an exchange and a queue should be declared and bound. If the ExchangeName is not defined (an empty string), the queue will not be bound to the exchange but assumed to use the default match.

func DirectBinding

func DirectBinding(routingKey string, handler HandlerFunc) HandlerBinding

DirectBinding returns a HandlerBinding to use for direct exchanges where each routing key will be mapped to one handler.

func FanoutBinding

func FanoutBinding(exchangeName string, handler HandlerFunc) HandlerBinding

FanoutBinding returns a HandlerBinding to use for fanout exchanges. These exchanges does not use the routing key. We do not use the default exchange (amq.fanout) since this would broadcast all messages everywhere.

func HeadersBinding

func HeadersBinding(queueName string, headers amqp.Table, handler HandlerFunc) HandlerBinding

HeadersBinding returns a HandlerBinding to use for header exchanges that will match on specific headers. The heades are specified as an amqp.Table. The default exchange amq.match will be used.

func TopicBinding

func TopicBinding(queueName, routingKey string, handler HandlerFunc) HandlerBinding

TopicBinding returns a HandlerBinding to use for topic exchanges. The default exchange (amq.topic) will be used. The topic is matched on the routing key.

type HandlerFunc

type HandlerFunc func(context.Context, *ResponseWriter, amqp.Delivery)

HandlerFunc is the function that handles all request based on the routing key.

func ServerMiddlewareChain

func ServerMiddlewareChain(next HandlerFunc, m ...ServerMiddlewareFunc) HandlerFunc

ServerMiddlewareChain will attatch all given middlewares to your HandlerFunc. The middlewares will be executed in the same order as your input.

For example:

s := New("url")

s.Bind(DirectBinding(
	"foobar",
	ServerMiddlewareChain(
		myHandler,
		middlewareOne,
		middlewareTwo,
		middlewareThree,
	),
))

type LogFunc

type LogFunc func(format string, args ...interface{})

LogFunc is used for logging in amqp-rpc. It makes it possible to define your own logging.

Here is an example where the logger from the log package is used:

debugLogger := log.New(os.Stdout, "DEBUG - ", log.LstdFlags)
errorLogger := log.New(os.Stdout, "ERROR - ", log.LstdFlags)

server := NewServer(url)
server.WithErrorLogger(errorLogger.Printf)
server.WithDebugLogger(debugLogger.Printf)

It can also be used with for example a Logrus logger:

logger := logrus.New()
logger.SetLevel(logrus.DebugLevel)
logger.Formatter = &logrus.JSONFormatter{}

s.WithErrorLogger(logger.Warnf)
s.WithDebugLogger(logger.Debugf)

client := NewClient(url)
client.WithErrorLogger(logger.Errorf)
client.WithDebugLogger(logger.Debugf)

type OnStartedFunc

type OnStartedFunc func(*amqp.Connection, *amqp.Connection, *amqp.Channel, *amqp.Channel)

OnStartedFunc is the function that can be passed to Server.OnStarted().

type PublishSettings

type PublishSettings struct {
	Mandatory bool
	Immediate bool
}

PublishSettings is the settings that will be used when a message is about to be published to the message bus.

type QosConfig

type QosConfig struct {
	PrefetchCount int
	PrefetchSize  int
	Global        bool
}

type QueueDeclareSettings

type QueueDeclareSettings struct {
	Durable          bool
	DeleteWhenUnused bool
	Exclusive        bool
	NoWait           bool
	Args             amqp.Table
}

QueueDeclareSettings is the settings that will be used when the response any kind of queue is declared. Se documentation for amqp.QueueDeclare for more information about these settings.

type Request

type Request struct {
	// Exchange is the exchange to which the rquest will be published when
	// passing it to the clients send function.
	Exchange string

	// Routing key is the routing key that will be used in the amqp.Publishing
	// request.
	RoutingKey string

	// Reply is a boolean value telling if the request should wait for a reply
	// or just send the request without waiting.
	Reply bool

	// Timeout is the time we should wait after a request is sent before
	// we assume the request got lost.
	Timeout time.Duration

	// Publishing is the publising that are going to be sent.
	Publishing amqp.Publishing

	// Context is a context which you can use to pass data from where the
	// request is created to middlewares. By default this will be a
	// context.Background()
	Context context.Context
	// contains filtered or unexported fields
}

Request is a requet to perform with the client

func NewRequest

func NewRequest() *Request

NewRequest will generate a new request to be published. The default request will use the content type "text/plain" and always wait for reply.

func (*Request) AddMiddleware

func (r *Request) AddMiddleware(m ClientMiddlewareFunc) *Request

AddMiddleware will add a middleware which will be executed when the request is sent.

func (*Request) WithBody

func (r *Request) WithBody(b string) *Request

WithBody will convert a string to a byte slice and add as the body passed for the request.

func (*Request) WithContentType

func (r *Request) WithContentType(ct string) *Request

WithContentType will update the content type passed in the header of the request. This value will bee set as the ContentType in the amqp.Publishing type but also preserved as a header value.

func (*Request) WithContext

func (r *Request) WithContext(ctx context.Context) *Request

WithContext will set the context on the request.

func (*Request) WithCorrelationID

func (r *Request) WithCorrelationID(id string) *Request

WithCorrelationID will add/overwrite the correlation ID used for the request and set it on the Publishing.

func (*Request) WithExchange

func (r *Request) WithExchange(e string) *Request

WithExchange will set the exchange on to which the request will be published.

func (*Request) WithHeaders

func (r *Request) WithHeaders(h amqp.Table) *Request

WithHeaders will set the full amqp.Table as the headers for the request. Note that this will overwrite anything previously set on the headers.

func (*Request) WithResponse

func (r *Request) WithResponse(wr bool) *Request

WithResponse sets the value determining wether the request should wait for a response or not. A request that does not require a response will only catch errors occurring before the reuqest has been published.

func (*Request) WithRoutingKey

func (r *Request) WithRoutingKey(rk string) *Request

WithRoutingKey will set the routing key for the request.

func (*Request) WithTimeout

func (r *Request) WithTimeout(t time.Duration) *Request

WithTimeout will set the client timeout used when publishing messages. t will be rounded using the duration's Round function to the nearest multiple of a millisecond. Rounding will be away from zero.

func (*Request) Write

func (r *Request) Write(p []byte) (int, error)

Write will write the response Body of the amqp.Publishing. It is safe to call Write multiple times.

func (*Request) WriteHeader

func (r *Request) WriteHeader(header string, value interface{})

WriteHeader will write a header for the specified key.

type ResponseWriter

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

ResponseWriter is used by a handler to construct an RPC response. The ResponseWriter may NOT be used after the handler has returned.

Because the ResponseWriter implements io.Writer you can for example use it to write json:

encoder := json.NewEncoder(responseWriter)
encoder.Encode(dataObject)

func NewResponseWriter

func NewResponseWriter(p *amqp.Publishing) *ResponseWriter

NewResponseWriter will create a new response writer with given amqp.Publishing.

func (*ResponseWriter) Immediate

func (rw *ResponseWriter) Immediate(i bool)

Immediate sets the immediate flag on the later amqp.Publish.

func (*ResponseWriter) Mandatory

func (rw *ResponseWriter) Mandatory(m bool)

Mandatory sets the mandatory flag on the later amqp.Publish.

func (*ResponseWriter) Publishing

func (rw *ResponseWriter) Publishing() *amqp.Publishing

Publishing returns the internal amqp.Publishing that are used for the response, useful for modification.

func (*ResponseWriter) Write

func (rw *ResponseWriter) Write(p []byte) (int, error)

Write will write the response Body of the amqp.Publishing. It is safe to call Write multiple times.

func (*ResponseWriter) WriteHeader

func (rw *ResponseWriter) WriteHeader(header string, value interface{})

WriteHeader will write a header for the specified key.

type SendFunc

type SendFunc func(r *Request) (d *amqp.Delivery, e error)

SendFunc represents the function that Send does. It takes a Request as input and returns a delivery and an error.

func ClientMiddlewareChain

func ClientMiddlewareChain(next SendFunc, m ...ClientMiddlewareFunc) SendFunc

ClientMiddlewareChain will attatch all given middlewares to your SendFunc. The middlewares will be executed in the same order as your input.

type Server

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

Server represents an AMQP server used within the RPC framework. The server uses bindings to keep a list of handler functions.

func NewServer

func NewServer(url string, qc QosConfig) *Server

NewServer will return a pointer to a new Server.

func (*Server) AddMiddleware

func (s *Server) AddMiddleware(m ServerMiddlewareFunc) *Server

AddMiddleware will add a ServerMiddleware to the list of middlewares to be triggered before the handle func for each request.

func (*Server) Bind

func (s *Server) Bind(binding HandlerBinding)

Bind will add a HandlerBinding to the list of servers to serve.

func (*Server) ListenAndServe

func (s *Server) ListenAndServe()

ListenAndServe will dial the RabbitMQ message bus, set up all the channels, consume from all RPC server queues and monitor to connection to ensure the server is always connected.

func (*Server) OnStarted

func (s *Server) OnStarted(f OnStartedFunc)

OnStarted can be used to hook into the connections/channels that the server is using. This can be useful if you want more control over amqp directly.

server := NewServer(url)
server.OnStarted(func(inConn, outConn *amqp.Connection, inChan, outChan *amqp.Channel) {
	// Do something with amqp connections/channels.
})

func (*Server) Stop

func (s *Server) Stop()

Stop will gracefully disconnect from AMQP after draining first incoming then outgoing messages. This method won't wait for server shutdown to complete, you should instead wait for ListenAndServe to exit.

func (*Server) WithDebugLogger

func (s *Server) WithDebugLogger(f LogFunc) *Server

WithDebugLogger sets the logger to use for debug logging.

func (*Server) WithDialConfig

func (s *Server) WithDialConfig(c amqp.Config) *Server

WithDialConfig sets the dial config used for the server.

func (*Server) WithErrorLogger

func (s *Server) WithErrorLogger(f LogFunc) *Server

WithErrorLogger sets the logger to use for error logging.

type ServerMiddlewareFunc

type ServerMiddlewareFunc func(next HandlerFunc) HandlerFunc

ServerMiddlewareFunc represent a function that can be used as a middleware.

For example:

func myMiddle(next HandlerFunc) HandlerFunc {

	// Preinitialization of middleware here.

	return func(ctx context.Context, rw *ResponseWriter d amqp.Delivery) {
		// Before handler execution here.

		// Execute the handler.
		next(ctx, rw, d)

		// After execution here.
	}
}

s := New("url")

// Add middleware to specific handler.
s.Bind(DirectBinding("foobar", myMiddle(HandlerFunc)))

// Add middleware to all handlers on the server.
s.AddMiddleware(myMiddle)

Directories

Path Synopsis
examples

Jump to

Keyboard shortcuts

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