2015-02-11 01:25:40 +00:00
|
|
|
package handlers
|
2015-01-06 18:37:27 +00:00
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
2015-01-28 23:55:18 +00:00
|
|
|
"net"
|
2015-01-06 18:37:27 +00:00
|
|
|
"net/http"
|
2015-01-28 23:55:18 +00:00
|
|
|
"os"
|
2015-01-06 18:37:27 +00:00
|
|
|
|
2015-01-28 23:55:18 +00:00
|
|
|
"code.google.com/p/go-uuid/uuid"
|
2015-02-12 00:49:49 +00:00
|
|
|
"github.com/docker/distribution"
|
2015-01-06 18:37:27 +00:00
|
|
|
"github.com/docker/distribution/configuration"
|
2015-02-07 00:19:19 +00:00
|
|
|
ctxu "github.com/docker/distribution/context"
|
2015-02-12 00:49:49 +00:00
|
|
|
"github.com/docker/distribution/notifications"
|
2015-02-11 02:14:23 +00:00
|
|
|
"github.com/docker/distribution/registry/api/v2"
|
|
|
|
"github.com/docker/distribution/registry/auth"
|
2015-02-11 01:41:09 +00:00
|
|
|
"github.com/docker/distribution/registry/storage"
|
2015-02-11 02:14:23 +00:00
|
|
|
storagedriver "github.com/docker/distribution/registry/storage/driver"
|
|
|
|
"github.com/docker/distribution/registry/storage/driver/factory"
|
2015-01-06 18:37:27 +00:00
|
|
|
"github.com/gorilla/mux"
|
2015-02-04 01:59:24 +00:00
|
|
|
"golang.org/x/net/context"
|
2015-01-06 18:37:27 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// App is a global registry application object. Shared resources can be placed
|
|
|
|
// on this object that will be accessible from all requests. Any writable
|
|
|
|
// fields should be protected.
|
|
|
|
type App struct {
|
2015-02-07 00:19:19 +00:00
|
|
|
context.Context
|
2015-01-06 18:37:27 +00:00
|
|
|
Config configuration.Configuration
|
|
|
|
|
2015-01-28 23:55:18 +00:00
|
|
|
// InstanceID is a unique id assigned to the application on each creation.
|
|
|
|
// Provides information in the logs and context to identify restarts.
|
|
|
|
InstanceID string
|
2015-01-06 18:37:27 +00:00
|
|
|
|
2015-01-28 23:55:18 +00:00
|
|
|
router *mux.Router // main application router, configured with dispatchers
|
|
|
|
driver storagedriver.StorageDriver // driver maintains the app global storage driver instance.
|
2015-02-12 00:49:49 +00:00
|
|
|
registry distribution.Registry // registry is the primary registry backend for the app instance.
|
2015-01-28 23:55:18 +00:00
|
|
|
accessController auth.AccessController // main access controller for application
|
2015-01-06 18:37:27 +00:00
|
|
|
|
2015-01-28 23:55:18 +00:00
|
|
|
// events contains notification related configuration.
|
|
|
|
events struct {
|
|
|
|
sink notifications.Sink
|
|
|
|
source notifications.SourceRecord
|
|
|
|
}
|
2015-01-09 00:55:40 +00:00
|
|
|
|
2015-01-28 23:55:18 +00:00
|
|
|
layerHandler storage.LayerHandler // allows dispatch of layer serving to external provider
|
2015-01-06 18:37:27 +00:00
|
|
|
}
|
|
|
|
|
2015-02-07 00:19:19 +00:00
|
|
|
// Value intercepts calls context.Context.Value, returning the current app id,
|
|
|
|
// if requested.
|
|
|
|
func (app *App) Value(key interface{}) interface{} {
|
|
|
|
switch key {
|
|
|
|
case "app.id":
|
|
|
|
return app.InstanceID
|
|
|
|
}
|
|
|
|
|
|
|
|
return app.Context.Value(key)
|
|
|
|
}
|
|
|
|
|
2015-01-06 18:37:27 +00:00
|
|
|
// NewApp takes a configuration and returns a configured app, ready to serve
|
|
|
|
// requests. The app only implements ServeHTTP and can be wrapped in other
|
|
|
|
// handlers accordingly.
|
2015-02-07 00:19:19 +00:00
|
|
|
func NewApp(ctx context.Context, configuration configuration.Configuration) *App {
|
2015-01-06 18:37:27 +00:00
|
|
|
app := &App{
|
2015-01-28 23:55:18 +00:00
|
|
|
Config: configuration,
|
2015-02-07 00:19:19 +00:00
|
|
|
Context: ctx,
|
2015-01-28 23:55:18 +00:00
|
|
|
InstanceID: uuid.New(),
|
|
|
|
router: v2.Router(),
|
2015-01-06 18:37:27 +00:00
|
|
|
}
|
|
|
|
|
2015-02-07 00:19:19 +00:00
|
|
|
app.Context = ctxu.WithLogger(app.Context, ctxu.GetLogger(app, "app.id"))
|
|
|
|
|
2015-01-06 18:37:27 +00:00
|
|
|
// Register the handler dispatchers.
|
|
|
|
app.register(v2.RouteNameBase, func(ctx *Context, r *http.Request) http.Handler {
|
|
|
|
return http.HandlerFunc(apiBase)
|
|
|
|
})
|
|
|
|
app.register(v2.RouteNameManifest, imageManifestDispatcher)
|
|
|
|
app.register(v2.RouteNameTags, tagsDispatcher)
|
|
|
|
app.register(v2.RouteNameBlob, layerDispatcher)
|
|
|
|
app.register(v2.RouteNameBlobUpload, layerUploadDispatcher)
|
|
|
|
app.register(v2.RouteNameBlobUploadChunk, layerUploadDispatcher)
|
|
|
|
|
2015-01-28 23:55:18 +00:00
|
|
|
var err error
|
|
|
|
app.driver, err = factory.Create(configuration.Storage.Type(), configuration.Storage.Parameters())
|
2015-01-06 18:37:27 +00:00
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
// TODO(stevvooe): Move the creation of a service into a protected
|
|
|
|
// method, where this is created lazily. Its status can be queried via
|
|
|
|
// a health check.
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
|
2015-01-28 23:55:18 +00:00
|
|
|
app.configureEvents(&configuration)
|
2015-01-17 02:32:27 +00:00
|
|
|
app.registry = storage.NewRegistryWithDriver(app.driver)
|
2015-01-06 18:37:27 +00:00
|
|
|
authType := configuration.Auth.Type()
|
|
|
|
|
|
|
|
if authType != "" {
|
|
|
|
accessController, err := auth.GetAccessController(configuration.Auth.Type(), configuration.Auth.Parameters())
|
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Sprintf("unable to configure authorization (%s): %v", authType, err))
|
|
|
|
}
|
|
|
|
app.accessController = accessController
|
|
|
|
}
|
|
|
|
|
2015-01-09 01:29:22 +00:00
|
|
|
layerHandlerType := configuration.LayerHandler.Type()
|
2015-01-09 00:55:40 +00:00
|
|
|
|
|
|
|
if layerHandlerType != "" {
|
2015-01-28 23:55:18 +00:00
|
|
|
lh, err := storage.GetLayerHandler(layerHandlerType, configuration.LayerHandler.Parameters(), app.driver)
|
2015-01-09 00:55:40 +00:00
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Sprintf("unable to configure layer handler (%s): %v", layerHandlerType, err))
|
|
|
|
}
|
|
|
|
app.layerHandler = lh
|
|
|
|
}
|
|
|
|
|
2015-01-06 18:37:27 +00:00
|
|
|
return app
|
|
|
|
}
|
|
|
|
|
|
|
|
// register a handler with the application, by route name. The handler will be
|
|
|
|
// passed through the application filters and context will be constructed at
|
|
|
|
// request time.
|
|
|
|
func (app *App) register(routeName string, dispatch dispatchFunc) {
|
|
|
|
|
|
|
|
// TODO(stevvooe): This odd dispatcher/route registration is by-product of
|
|
|
|
// some limitations in the gorilla/mux router. We are using it to keep
|
|
|
|
// routing consistent between the client and server, but we may want to
|
|
|
|
// replace it with manual routing and structure-based dispatch for better
|
|
|
|
// control over the request execution.
|
|
|
|
|
|
|
|
app.router.GetRoute(routeName).Handler(app.dispatcher(dispatch))
|
|
|
|
}
|
|
|
|
|
2015-01-28 23:55:18 +00:00
|
|
|
// configureEvents prepares the event sink for action.
|
|
|
|
func (app *App) configureEvents(configuration *configuration.Configuration) {
|
|
|
|
// Configure all of the endpoint sinks.
|
|
|
|
var sinks []notifications.Sink
|
|
|
|
for _, endpoint := range configuration.Notifications.Endpoints {
|
|
|
|
if endpoint.Disabled {
|
2015-02-07 00:19:19 +00:00
|
|
|
ctxu.GetLogger(app).Infof("endpoint %s disabled, skipping", endpoint.Name)
|
2015-01-28 23:55:18 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2015-02-07 00:19:19 +00:00
|
|
|
ctxu.GetLogger(app).Infof("configuring endpoint %v (%v), timeout=%s, headers=%v", endpoint.Name, endpoint.URL, endpoint.Timeout, endpoint.Headers)
|
2015-01-28 23:55:18 +00:00
|
|
|
endpoint := notifications.NewEndpoint(endpoint.Name, endpoint.URL, notifications.EndpointConfig{
|
|
|
|
Timeout: endpoint.Timeout,
|
|
|
|
Threshold: endpoint.Threshold,
|
|
|
|
Backoff: endpoint.Backoff,
|
|
|
|
Headers: endpoint.Headers,
|
|
|
|
})
|
|
|
|
|
|
|
|
sinks = append(sinks, endpoint)
|
|
|
|
}
|
|
|
|
|
|
|
|
// NOTE(stevvooe): Moving to a new queueing implementation is as easy as
|
|
|
|
// replacing broadcaster with a rabbitmq implementation. It's recommended
|
|
|
|
// that the registry instances also act as the workers to keep deployment
|
|
|
|
// simple.
|
|
|
|
app.events.sink = notifications.NewBroadcaster(sinks...)
|
|
|
|
|
|
|
|
// Populate registry event source
|
|
|
|
hostname, err := os.Hostname()
|
|
|
|
if err != nil {
|
|
|
|
hostname = configuration.HTTP.Addr
|
|
|
|
} else {
|
|
|
|
// try to pick the port off the config
|
|
|
|
_, port, err := net.SplitHostPort(configuration.HTTP.Addr)
|
|
|
|
if err == nil {
|
|
|
|
hostname = net.JoinHostPort(hostname, port)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
app.events.source = notifications.SourceRecord{
|
|
|
|
Addr: hostname,
|
|
|
|
InstanceID: app.InstanceID,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (app *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
|
|
defer r.Body.Close() // ensure that request body is always closed.
|
|
|
|
|
|
|
|
// Set a header with the Docker Distribution API Version for all responses.
|
|
|
|
w.Header().Add("Docker-Distribution-API-Version", "registry/2.0")
|
|
|
|
app.router.ServeHTTP(w, r)
|
|
|
|
}
|
|
|
|
|
2015-01-06 18:37:27 +00:00
|
|
|
// dispatchFunc takes a context and request and returns a constructed handler
|
|
|
|
// for the route. The dispatcher will use this to dynamically create request
|
|
|
|
// specific handlers for each endpoint without creating a new router for each
|
|
|
|
// request.
|
|
|
|
type dispatchFunc func(ctx *Context, r *http.Request) http.Handler
|
|
|
|
|
|
|
|
// TODO(stevvooe): dispatchers should probably have some validation error
|
|
|
|
// chain with proper error reporting.
|
|
|
|
|
|
|
|
// singleStatusResponseWriter only allows the first status to be written to be
|
|
|
|
// the valid request status. The current use case of this class should be
|
|
|
|
// factored out.
|
|
|
|
type singleStatusResponseWriter struct {
|
|
|
|
http.ResponseWriter
|
|
|
|
status int
|
|
|
|
}
|
|
|
|
|
|
|
|
func (ssrw *singleStatusResponseWriter) WriteHeader(status int) {
|
|
|
|
if ssrw.status != 0 {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
ssrw.status = status
|
|
|
|
ssrw.ResponseWriter.WriteHeader(status)
|
|
|
|
}
|
|
|
|
|
2015-02-07 00:19:19 +00:00
|
|
|
func (ssrw *singleStatusResponseWriter) Flush() {
|
|
|
|
if flusher, ok := ssrw.ResponseWriter.(http.Flusher); ok {
|
|
|
|
flusher.Flush()
|
|
|
|
}
|
2015-02-04 01:59:24 +00:00
|
|
|
}
|
|
|
|
|
2015-01-06 18:37:27 +00:00
|
|
|
// dispatcher returns a handler that constructs a request specific context and
|
|
|
|
// handler, using the dispatch factory function.
|
|
|
|
func (app *App) dispatcher(dispatch dispatchFunc) http.Handler {
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
2015-02-07 00:19:19 +00:00
|
|
|
context := app.context(w, r)
|
2015-01-06 18:37:27 +00:00
|
|
|
|
2015-02-07 00:19:19 +00:00
|
|
|
defer func() {
|
|
|
|
ctxu.GetResponseLogger(context).Infof("response completed")
|
|
|
|
}()
|
|
|
|
|
|
|
|
if err := app.authorized(w, r, context); err != nil {
|
2015-02-10 23:19:02 +00:00
|
|
|
ctxu.GetLogger(context).Errorf("error authorizing context: %v", err)
|
2015-01-06 18:37:27 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2015-02-13 21:59:50 +00:00
|
|
|
if app.nameRequired(r) {
|
|
|
|
repository, err := app.registry.Repository(context, getName(context))
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
ctxu.GetLogger(context).Errorf("error resolving repository: %v", err)
|
|
|
|
|
|
|
|
switch err := err.(type) {
|
|
|
|
case distribution.ErrRepositoryUnknown:
|
|
|
|
context.Errors.Push(v2.ErrorCodeNameUnknown, err)
|
|
|
|
case distribution.ErrRepositoryNameInvalid:
|
|
|
|
context.Errors.Push(v2.ErrorCodeNameInvalid, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
serveJSON(w, context.Errors)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// assign and decorate the authorized repository with an event bridge.
|
|
|
|
context.Repository = notifications.Listen(
|
|
|
|
repository,
|
|
|
|
app.eventBridge(context, r))
|
|
|
|
}
|
|
|
|
|
2015-01-06 18:37:27 +00:00
|
|
|
handler := dispatch(context, r)
|
|
|
|
|
|
|
|
ssrw := &singleStatusResponseWriter{ResponseWriter: w}
|
|
|
|
handler.ServeHTTP(ssrw, r)
|
|
|
|
|
|
|
|
// Automated error response handling here. Handlers may return their
|
|
|
|
// own errors if they need different behavior (such as range errors
|
|
|
|
// for layer upload).
|
|
|
|
if context.Errors.Len() > 0 {
|
|
|
|
if ssrw.status == 0 {
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
}
|
|
|
|
serveJSON(w, context.Errors)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// context constructs the context object for the application. This only be
|
|
|
|
// called once per request.
|
2015-02-07 00:19:19 +00:00
|
|
|
func (app *App) context(w http.ResponseWriter, r *http.Request) *Context {
|
|
|
|
ctx := ctxu.WithRequest(app, r)
|
|
|
|
ctx, w = ctxu.WithResponseWriter(ctx, w)
|
|
|
|
ctx = ctxu.WithVars(ctx, r)
|
|
|
|
ctx = ctxu.WithLogger(ctx, ctxu.GetRequestLogger(ctx))
|
|
|
|
ctx = ctxu.WithLogger(ctx, ctxu.GetLogger(ctx,
|
|
|
|
"vars.name",
|
|
|
|
"vars.tag",
|
|
|
|
"vars.digest",
|
|
|
|
"vars.tag",
|
|
|
|
"vars.uuid"))
|
|
|
|
|
2015-01-06 18:37:27 +00:00
|
|
|
context := &Context{
|
|
|
|
App: app,
|
2015-02-07 00:19:19 +00:00
|
|
|
Context: ctx,
|
2015-01-06 18:37:27 +00:00
|
|
|
urlBuilder: v2.NewURLBuilderFromRequest(r),
|
|
|
|
}
|
|
|
|
|
|
|
|
return context
|
|
|
|
}
|
|
|
|
|
2015-01-17 02:32:27 +00:00
|
|
|
// authorized checks if the request can proceed with access to the requested
|
2015-02-10 23:19:02 +00:00
|
|
|
// repository. If it succeeds, the context may access the requested
|
|
|
|
// repository. An error will be returned if access is not available.
|
2015-02-07 00:19:19 +00:00
|
|
|
func (app *App) authorized(w http.ResponseWriter, r *http.Request, context *Context) error {
|
|
|
|
ctxu.GetLogger(context).Debug("authorizing request")
|
|
|
|
repo := getName(context)
|
|
|
|
|
2015-01-06 18:37:27 +00:00
|
|
|
if app.accessController == nil {
|
|
|
|
return nil // access controller is not enabled.
|
|
|
|
}
|
|
|
|
|
|
|
|
var accessRecords []auth.Access
|
|
|
|
|
2015-01-17 02:32:27 +00:00
|
|
|
if repo != "" {
|
2015-01-06 18:37:27 +00:00
|
|
|
resource := auth.Resource{
|
|
|
|
Type: "repository",
|
2015-01-17 02:32:27 +00:00
|
|
|
Name: repo,
|
2015-01-06 18:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
switch r.Method {
|
|
|
|
case "GET", "HEAD":
|
|
|
|
accessRecords = append(accessRecords,
|
|
|
|
auth.Access{
|
|
|
|
Resource: resource,
|
|
|
|
Action: "pull",
|
|
|
|
})
|
|
|
|
case "POST", "PUT", "PATCH":
|
|
|
|
accessRecords = append(accessRecords,
|
|
|
|
auth.Access{
|
|
|
|
Resource: resource,
|
|
|
|
Action: "pull",
|
|
|
|
},
|
|
|
|
auth.Access{
|
|
|
|
Resource: resource,
|
|
|
|
Action: "push",
|
|
|
|
})
|
|
|
|
case "DELETE":
|
|
|
|
// DELETE access requires full admin rights, which is represented
|
|
|
|
// as "*". This may not be ideal.
|
|
|
|
accessRecords = append(accessRecords,
|
|
|
|
auth.Access{
|
|
|
|
Resource: resource,
|
|
|
|
Action: "*",
|
|
|
|
})
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// Only allow the name not to be set on the base route.
|
2015-02-13 21:59:50 +00:00
|
|
|
if app.nameRequired(r) {
|
2015-02-10 23:19:02 +00:00
|
|
|
// For this to be properly secured, repo must always be set for a
|
|
|
|
// resource that may make a modification. The only condition under
|
|
|
|
// which name is not set and we still allow access is when the
|
|
|
|
// base route is accessed. This section prevents us from making
|
|
|
|
// that mistake elsewhere in the code, allowing any operation to
|
|
|
|
// proceed.
|
2015-01-06 18:37:27 +00:00
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
w.WriteHeader(http.StatusForbidden)
|
|
|
|
|
|
|
|
var errs v2.Errors
|
|
|
|
errs.Push(v2.ErrorCodeUnauthorized)
|
|
|
|
serveJSON(w, errs)
|
2015-02-10 23:19:02 +00:00
|
|
|
return fmt.Errorf("forbidden: no repository name")
|
2015-01-06 18:37:27 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2015-02-07 00:19:19 +00:00
|
|
|
ctx, err := app.accessController.Authorized(context.Context, accessRecords...)
|
2015-02-04 01:59:24 +00:00
|
|
|
if err != nil {
|
2015-01-06 18:37:27 +00:00
|
|
|
switch err := err.(type) {
|
|
|
|
case auth.Challenge:
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
err.ServeHTTP(w, r)
|
|
|
|
|
|
|
|
var errs v2.Errors
|
|
|
|
errs.Push(v2.ErrorCodeUnauthorized, accessRecords)
|
|
|
|
serveJSON(w, errs)
|
|
|
|
default:
|
|
|
|
// This condition is a potential security problem either in
|
|
|
|
// the configuration or whatever is backing the access
|
|
|
|
// controller. Just return a bad request with no information
|
|
|
|
// to avoid exposure. The request should not proceed.
|
2015-02-07 00:19:19 +00:00
|
|
|
ctxu.GetLogger(context).Errorf("error checking authorization: %v", err)
|
2015-01-06 18:37:27 +00:00
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
}
|
|
|
|
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2015-02-09 22:44:58 +00:00
|
|
|
// TODO(stevvooe): This pattern needs to be cleaned up a bit. One context
|
|
|
|
// should be replaced by another, rather than replacing the context on a
|
|
|
|
// mutable object.
|
2015-02-07 00:19:19 +00:00
|
|
|
context.Context = ctx
|
2015-02-04 01:59:24 +00:00
|
|
|
|
2015-01-06 18:37:27 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2015-01-28 23:55:18 +00:00
|
|
|
// eventBridge returns a bridge for the current request, configured with the
|
|
|
|
// correct actor and source.
|
|
|
|
func (app *App) eventBridge(ctx *Context, r *http.Request) notifications.Listener {
|
|
|
|
actor := notifications.ActorRecord{
|
2015-02-07 00:19:19 +00:00
|
|
|
Name: getUserName(ctx, r),
|
2015-01-28 23:55:18 +00:00
|
|
|
}
|
2015-02-07 00:19:19 +00:00
|
|
|
request := notifications.NewRequestRecord(ctxu.GetRequestID(ctx), r)
|
2015-01-28 23:55:18 +00:00
|
|
|
|
2015-02-03 21:28:10 +00:00
|
|
|
return notifications.NewBridge(ctx.urlBuilder, app.events.source, actor, request, app.events.sink)
|
2015-01-28 23:55:18 +00:00
|
|
|
}
|
|
|
|
|
2015-02-13 21:59:50 +00:00
|
|
|
// nameRequired returns true if the route requires a name.
|
|
|
|
func (app *App) nameRequired(r *http.Request) bool {
|
|
|
|
route := mux.CurrentRoute(r)
|
|
|
|
return route == nil || route.GetName() != v2.RouteNameBase
|
|
|
|
}
|
|
|
|
|
2015-01-06 18:37:27 +00:00
|
|
|
// apiBase implements a simple yes-man for doing overall checks against the
|
|
|
|
// api. This can support auth roundtrips to support docker login.
|
|
|
|
func apiBase(w http.ResponseWriter, r *http.Request) {
|
|
|
|
const emptyJSON = "{}"
|
|
|
|
// Provide a simple /v2/ 200 OK response with empty json response.
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
w.Header().Set("Content-Length", fmt.Sprint(len(emptyJSON)))
|
|
|
|
|
|
|
|
fmt.Fprint(w, emptyJSON)
|
|
|
|
}
|