Repository: fsouza/fake-gcs-server
Pull request #490 (2021-04-28 20:35:32)

Continuation of event support PR
This pr is a continuation of PR #370 created by @isaldana . I hope its okay that I picked up this work. This would be a really great feature to have available. If this pr is accepted I want to make sure the work is attributed to @isaldana. I was sure to rebase that work to retain their commits.

I've addressed the comment left on his pr about moving event options to a separate struct. I've also made a few fixes including:
- a typo in an option name
- the event generated from object creations didn't include the metadata from the created object
- the workaround for a gsutil cp issue discovered by @StephenWithPH here https://github.com/fsouza/fake-gcs-server/issues/217#issuecomment-679443353 . i have done some digging on the next error but have yet to discover a solution
-  Always add localhost route rule to ensure requests via another network (like docker bridge) succeed. addresses issue encountered in #280 

@isaldana please feel free to veto this pr if you prefer i dont submit it on your behalf


isaldana: @terev Thanks so much for addressing the comments on the PR and sorry for the late reply. This looks good to me. Thanks again!
fsouza: Hey @terev, I'm sorry I neglected your PR. I'll find some time this week to look into it.
terev: no worries! I appreciate it
fsouza: (when you rebase, can you make sure that events to any new endpoints, such as compose?)
mlhamel: Any updates on this one? That would be awesome to have this branch being part of an official release 🙏 
terev: @mlhamel sorry i've neglected this for too long. i'll get this pr ready for review sometime this week
terev: @fsouza I've completed the following:
- rebased
- moved event.go to dedicated package (notifications)
- added support for archive events
- added support for compose endpoint
- added support for update metadata endpoint
- removed extraneous changes
fsouza: @terev btw, can you fix the lint failure? (just need `goimports -w` I think). Not a problem if you're busy or off for the holidays, I can definitely do it before merging.

Patch:
diff --git a/fakestorage/object.go b/fakestorage/object.go
index 1371aad..fb71f1c 100644
--- a/fakestorage/object.go
+++ b/fakestorage/object.go
@@ -8,6 +8,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"github.com/fsouza/fake-gcs-server/internal/notification"
 	"io"
 	"math"
 	"net/http"
@@ -233,12 +234,35 @@ func (s *Server) CreateObject(obj Object) {
 }
 
 func (s *Server) createObject(obj Object) (Object, error) {
-	newObj, err := s.backend.CreateObject(toBackendObjects([]Object{obj})[0])
+	oldBackendObj, err := s.backend.GetObject(obj.BucketName, obj.Name)
+	prevVersionExisted := err == nil
+
+	newBackendObj, err := s.backend.CreateObject(toBackendObjects([]Object{obj})[0])
 	if err != nil {
 		return Object{}, err
 	}
 
-	return fromBackendObjects([]backend.Object{newObj})[0], nil
+	var newObjEventAttr map[string]string
+	if prevVersionExisted {
+		newObjEventAttr = map[string]string{
+			"overwroteGeneration": strconv.FormatInt(oldBackendObj.Generation, 10),
+		}
+
+		oldObjEventAttr := map[string]string{
+			"overwrittenByGeneration": strconv.FormatInt(newBackendObj.Generation, 10),
+		}
+
+		bucket, _ := s.backend.GetBucket(obj.BucketName)
+		if bucket.VersioningEnabled {
+			s.eventManager.Trigger(&oldBackendObj, notification.EventArchive, oldObjEventAttr)
+		} else {
+			s.eventManager.Trigger(&oldBackendObj, notification.EventDelete, oldObjEventAttr)
+		}
+	}
+
+	newObj := fromBackendObjects([]backend.Object{newBackendObj})[0]
+	s.eventManager.Trigger(&newBackendObj, notification.EventFinalize, newObjEventAttr)
+	return newObj, nil
 }
 
 type ListOptions struct {
@@ -476,10 +500,20 @@ func (s *Server) getObject(w http.ResponseWriter, r *http.Request) {
 
 func (s *Server) deleteObject(r *http.Request) jsonResponse {
 	vars := mux.Vars(r)
-	err := s.backend.DeleteObject(vars["bucketName"], vars["objectName"])
+	obj, err := s.GetObject(vars["bucketName"], vars["objectName"])
+	if err == nil {
+		err = s.backend.DeleteObject(vars["bucketName"], vars["objectName"])
+	}
 	if err != nil {
 		return jsonResponse{status: http.StatusNotFound}
 	}
+	bucket, _ := s.backend.GetBucket(obj.BucketName)
+	backendObj := toBackendObjects([]Object{obj})[0]
+	if bucket.VersioningEnabled {
+		s.eventManager.Trigger(&backendObj, notification.EventArchive, nil)
+	} else {
+		s.eventManager.Trigger(&backendObj, notification.EventDelete, nil)
+	}
 	return jsonResponse{}
 }
 
@@ -658,6 +692,8 @@ func (s *Server) patchObject(r *http.Request) jsonResponse {
 			errorMessage: "Object not found to be PATCHed",
 		}
 	}
+
+	s.eventManager.Trigger(&backendObj, notification.EventMetadata, nil)
 	return jsonResponse{data: fromBackendObjects([]backend.Object{backendObj})[0]}
 }
 
@@ -682,6 +718,8 @@ func (s *Server) updateObject(r *http.Request) jsonResponse {
 			errorMessage: "Object not found to be updated",
 		}
 	}
+
+	s.eventManager.Trigger(&backendObj, notification.EventMetadata, nil)
 	return jsonResponse{data: fromBackendObjects([]backend.Object{backendObj})[0]}
 }
 
@@ -725,5 +763,7 @@ func (s *Server) composeObject(r *http.Request) jsonResponse {
 
 	obj := fromBackendObjects([]backend.Object{backendObj})[0]
 
+	s.eventManager.Trigger(&backendObj, notification.EventFinalize, nil)
+
 	return jsonResponse{data: newObjectResponse(obj.ObjectAttrs)}
 }
diff --git a/fakestorage/server.go b/fakestorage/server.go
index 84cb9d2..bea57d4 100644
--- a/fakestorage/server.go
+++ b/fakestorage/server.go
@@ -8,6 +8,7 @@ import (
 	"compress/gzip"
 	"context"
 	"fmt"
+	"github.com/fsouza/fake-gcs-server/internal/notification"
 	"io"
 	"mime"
 	"net"
@@ -29,14 +30,15 @@ const defaultPublicHost = "storage.googleapis.com"
 //
 // It provides a fake implementation of the Google Cloud Storage API.
 type Server struct {
-	backend     backend.Storage
-	uploads     sync.Map
-	transport   http.RoundTripper
-	ts          *httptest.Server
-	mux         *mux.Router
-	options     Options
-	externalURL string
-	publicHost  string
+	backend      backend.Storage
+	uploads      sync.Map
+	transport    http.RoundTripper
+	ts           *httptest.Server
+	mux          *mux.Router
+	options      Options
+	externalURL  string
+	publicHost   string
+	eventManager notification.EventManager
 }
 
 // NewServer creates a new instance of the server, pre-loaded with the given
@@ -93,6 +95,10 @@ type Options struct {
 
 	// Destination for writing log.
 	Writer io.Writer
+
+	// EventOptions contains the events that should be published and the URL
+	// of the Google cloud function such events should be published to.
+	EventOptions notification.EventManagerOptions
 }
 
 // NewServerWithOptions creates a new server configured according to the
@@ -130,6 +136,11 @@ func NewServerWithOptions(options Options) (*Server, error) {
 		return s, nil
 	}
 
+	s.eventManager, err = notification.NewPubsubEventManager(options.EventOptions, options.Writer)
+	if err != nil {
+		return nil, err
+	}
+
 	s.ts = httptest.NewUnstartedServer(handler)
 	startFunc := s.ts.StartTLS
 	if options.Scheme == "http" {
@@ -165,12 +176,14 @@ func newServer(options Options) (*Server, error) {
 	if publicHost == "" {
 		publicHost = defaultPublicHost
 	}
+
 	s := Server{
-		backend:     backendStorage,
-		uploads:     sync.Map{},
-		externalURL: options.ExternalURL,
-		publicHost:  publicHost,
-		options:     options,
+		backend:      backendStorage,
+		uploads:      sync.Map{},
+		externalURL:  options.ExternalURL,
+		publicHost:   publicHost,
+		options:      options,
+		eventManager: &notification.PubsubEventManager{},
 	}
 	s.buildMuxer()
 	return &s, nil
diff --git a/go.mod b/go.mod
index eb34442..2a18f6b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,29 +1,35 @@
 module github.com/fsouza/fake-gcs-server
 
 require (
+	cloud.google.com/go/pubsub v1.10.0
 	cloud.google.com/go/storage v1.18.2
 	github.com/google/go-cmp v0.5.6
 	github.com/gorilla/handlers v1.5.1
 	github.com/gorilla/mux v1.8.0
 	github.com/sirupsen/logrus v1.8.1
+	github.com/stretchr/testify v1.6.1
 	golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
 	google.golang.org/api v0.63.0
 )
 
 require (
 	cloud.google.com/go v0.99.0 // indirect
+	cloud.google.com/go/kms v1.1.0 // indirect
 	github.com/census-instrumentation/opencensus-proto v0.2.1 // indirect
 	github.com/cespare/xxhash v1.1.0 // indirect
 	github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 // indirect
 	github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0 // indirect
 	github.com/envoyproxy/protoc-gen-validate v0.1.0 // indirect
 	github.com/felixge/httpsnoop v1.0.1 // indirect
 	github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/googleapis/gax-go/v2 v2.1.1 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
 	golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 // indirect
+	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
 	golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect
 	golang.org/x/text v0.3.6 // indirect
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
@@ -31,6 +37,7 @@ require (
 	google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
 	google.golang.org/grpc v1.40.1 // indirect
 	google.golang.org/protobuf v1.27.1 // indirect
+	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
 )
 
 go 1.17
diff --git a/go.sum b/go.sum
index 68d5339..bc49661 100644
--- a/go.sum
+++ b/go.sum
@@ -15,6 +15,7 @@ cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOY
 cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
 cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
 cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
+cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
 cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
 cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
 cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
@@ -35,10 +36,14 @@ cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4g
 cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
+cloud.google.com/go/kms v1.1.0 h1:1yc4rLqCkVDS9Zvc7m+3mJ47kw0Uo5Q5+sMjcmUVUeM=
+cloud.google.com/go/kms v1.1.0/go.mod h1:WdbppnCDMDpOvoYBMn1+gNmOeEoZYqAv+HeuKARGCXI=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
 cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
 cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
 cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
+cloud.google.com/go/pubsub v1.10.0 h1:JK22g5uNpscGPthjJE/D0siWtA6UlU4Cb6pLcyJkzyQ=
+cloud.google.com/go/pubsub v1.10.0/go.mod h1:eNpTrkOy7dCpkNyaSNetMa6udbgecJMd0ZsTJS/cuNo=
 cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
 cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
 cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
@@ -148,6 +153,7 @@ github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hf
 github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
 github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
@@ -173,8 +179,10 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -275,6 +283,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
 golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
@@ -289,6 +298,7 @@ golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4Iltr
 golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210113205817-d3ed898aa8a3/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
@@ -309,6 +319,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -342,6 +353,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -373,6 +385,7 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -418,6 +431,7 @@ golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4f
 golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
 golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
@@ -447,6 +461,7 @@ google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSr
 google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
 google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
 google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
+google.golang.org/api v0.39.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
 google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
 google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
 google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
@@ -505,6 +520,8 @@ google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210207032614-bba0dbe2a9ea/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
@@ -528,6 +545,7 @@ google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEc
 google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
 google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211018162055-cf77aa76bad2/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa h1:I0YcKz0I7OAhddo7ya8kMnvprhcWM045PmkBdMO9zN0=
@@ -575,6 +593,7 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
 google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
 google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/internal/config/config.go b/internal/config/config.go
index e18604c..01b665c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -9,6 +9,7 @@ package config
 import (
 	"flag"
 	"fmt"
+	"github.com/fsouza/fake-gcs-server/internal/notification"
 	"math"
 	"strings"
 
@@ -17,8 +18,12 @@ import (
 )
 
 const (
-	filesystemBackend = "filesystem"
-	memoryBackend     = "memory"
+	filesystemBackend   = "filesystem"
+	memoryBackend       = "memory"
+	eventFinalize       = "finalize"
+	eventDelete         = "delete"
+	eventMetadataUpdate = "metadataUpdate"
+	eventArchive        = "archive"
 )
 
 type Config struct {
@@ -31,6 +36,14 @@ type Config struct {
 	port               uint
 	backend            string
 	fsRoot             string
+	event              EventConfig
+}
+
+type EventConfig struct {
+	pubsubProjectID string
+	pubsubTopic     string
+	prefix          string
+	list            []string
 }
 
 // Load parses the given arguments list and return a config object (and/or an
@@ -38,6 +51,7 @@ type Config struct {
 func Load(args []string) (Config, error) {
 	var cfg Config
 	var allowedCORSHeaders string
+	var eventList string
 
 	fs := flag.NewFlagSet("fake-gcs-server", flag.ContinueOnError)
 	fs.StringVar(&cfg.backend, "backend", filesystemBackend, "storage backend (memory or filesystem)")
@@ -49,6 +63,10 @@ func Load(args []string) (Config, error) {
 	fs.StringVar(&cfg.Seed, "data", "", "where to load data from (provided that the directory exists)")
 	fs.StringVar(&allowedCORSHeaders, "cors-headers", "", "comma separated list of headers to add to the CORS allowlist")
 	fs.UintVar(&cfg.port, "port", 4443, "port to bind to")
+	fs.StringVar(&cfg.event.pubsubProjectID, "event.pubsub-project-id", "", "project ID containing the pubsub topic")
+	fs.StringVar(&cfg.event.pubsubTopic, "event.pubsub-topic", "", "pubsub topic name to publish events on")
+	fs.StringVar(&cfg.event.prefix, "event.object-prefix", "", "if not empty, only objects having this prefix will generate trigger events")
+	fs.StringVar(&eventList, "event.list", eventFinalize, "comma separated list of events to publish on cloud function URl. Options are: finalize, delete, and metadataUpdate")
 
 	err := fs.Parse(args)
 	if err != nil {
@@ -58,6 +76,9 @@ func Load(args []string) (Config, error) {
 	if allowedCORSHeaders != "" {
 		cfg.allowedCORSHeaders = strings.Split(allowedCORSHeaders, ",")
 	}
+	if eventList != "" {
+		cfg.event.list = strings.Split(eventList, ",")
+	}
 
 	return cfg, cfg.validate()
 }
@@ -75,6 +96,34 @@ func (c *Config) validate() error {
 	if c.port > math.MaxUint16 {
 		return fmt.Errorf("port %d is too high, maximum value is %d", c.port, math.MaxUint16)
 	}
+
+	return c.event.validate()
+}
+
+func (c *EventConfig) validate() error {
+	switch c.pubsubProjectID {
+	case "":
+		if c.pubsubTopic != "" {
+			return fmt.Errorf("missing event pubsub project ID")
+		}
+	default:
+		if c.pubsubTopic == "" {
+			return fmt.Errorf("missing event pubsub topic ID")
+		}
+		for i, event := range c.list {
+			e := strings.TrimSpace(event)
+			switch e {
+			case eventFinalize, eventDelete, eventMetadataUpdate, eventArchive:
+			default:
+				return fmt.Errorf("%s is an invalid event", e)
+			}
+			c.list[i] = e
+		}
+		if len(c.list) == 0 {
+			return fmt.Errorf("event list cannot be empty")
+		}
+	}
+
 	return nil
 }
 
@@ -83,6 +132,26 @@ func (c *Config) ToFakeGcsOptions() fakestorage.Options {
 	if c.backend == memoryBackend {
 		storageRoot = ""
 	}
+	eventOptions := notification.EventManagerOptions{
+		ProjectID:    c.event.pubsubProjectID,
+		TopicName:    c.event.pubsubTopic,
+		ObjectPrefix: c.event.prefix,
+	}
+	if c.event.pubsubProjectID != "" && c.event.pubsubTopic != "" {
+		for _, event := range c.event.list {
+			switch event {
+			case eventFinalize:
+				eventOptions.NotifyOn.Finalize = true
+			case eventDelete:
+				eventOptions.NotifyOn.Delete = true
+			case eventMetadataUpdate:
+				eventOptions.NotifyOn.MetadataUpdate = true
+			case eventArchive:
+				eventOptions.NotifyOn.Archive = true
+			}
+		}
+	}
+
 	return fakestorage.Options{
 		StorageRoot:        storageRoot,
 		Scheme:             c.scheme,
@@ -92,5 +161,6 @@ func (c *Config) ToFakeGcsOptions() fakestorage.Options {
 		ExternalURL:        c.externalURL,
 		AllowedCORSHeaders: c.allowedCORSHeaders,
 		Writer:             logrus.New().Writer(),
+		EventOptions:       eventOptions,
 	}
 }
diff --git a/internal/notification/event.go b/internal/notification/event.go
new file mode 100644
index 0000000..bcf45b3
--- /dev/null
+++ b/internal/notification/event.go
@@ -0,0 +1,214 @@
+package notification
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"github.com/fsouza/fake-gcs-server/internal/backend"
+	"io"
+	"strconv"
+	"strings"
+	"time"
+
+	"cloud.google.com/go/pubsub"
+)
+
+// EventType is the type of event to trigger. The descriptions of the events
+// can be found here:
+// https://cloud.google.com/storage/docs/pubsub-notifications#events.
+type EventType string
+
+const (
+	// EventFinalize is triggered when an object is added.
+	EventFinalize EventType = "OBJECT_FINALIZE"
+	// EventDelete is triggered when an object is deleted.
+	EventDelete = "OBJECT_DELETE"
+	// EventMetadata is triggered when an object's metadata is changed.
+	EventMetadata = "OBJECT_METADATA_UPDATE"
+	// EventArchive bucket versioning must be enabled. is triggered when an object becomes the non current version
+	EventArchive = "OBJECT_ARCHIVE"
+)
+
+// EventNotificationOptions contains flags for events, that if true, will create
+// trigger notifications when they occur.
+type EventNotificationOptions struct {
+	Finalize       bool
+	Delete         bool
+	MetadataUpdate bool
+	Archive        bool
+}
+
+// EventManagerOptions determines what events are triggered and where.
+type EventManagerOptions struct {
+	// ProjectID is the project ID containing the pubsub topic.
+	ProjectID string
+	// TopicName is the pubsub topic name to publish events on.
+	TopicName string
+	// ObjectPrefix, if not empty, only objects having this prefix will generate
+	// trigger events.
+	ObjectPrefix string
+	// NotifyOn determines what events to trigger.
+	NotifyOn EventNotificationOptions
+}
+
+type EventManager interface {
+	Trigger(o *backend.Object, eventType EventType, extraEventAttr map[string]string)
+}
+
+// PubsubEventManager checks if an event should be published.
+type PubsubEventManager struct {
+	// publishSynchronously is a flag that if true, events will be published
+	// synchronously and not in a goroutine. It is used during tests to prevent
+	// race conditions.
+	publishSynchronously bool
+	// notifyOn determines what events are triggered.
+	notifyOn EventNotificationOptions
+	// writer is where logs are written to.
+	writer io.Writer
+	// objectPrefix, if not empty, only objects having this prefix will generate
+	// trigger events.
+	objectPrefix string
+	//  publisher is used to publish events on.
+	publisher eventPublisher
+}
+
+func NewPubsubEventManager(options EventManagerOptions, w io.Writer) (*PubsubEventManager, error) {
+	manager := &PubsubEventManager{
+		writer:       w,
+		notifyOn:     options.NotifyOn,
+		objectPrefix: options.ObjectPrefix,
+	}
+	if options.ProjectID != "" && options.TopicName != "" {
+		ctx := context.Background()
+		client, err := pubsub.NewClient(ctx, options.ProjectID)
+		if err != nil {
+			return nil, fmt.Errorf("error creating pubsub client: %v", err)
+		}
+		manager.publisher = client.Topic(options.TopicName)
+	}
+	return manager, nil
+}
+
+// eventPublisher is the interface to publish triggered events.
+type eventPublisher interface {
+	Publish(ctx context.Context, msg *pubsub.Message) *pubsub.PublishResult
+}
+
+// Trigger checks if an event should be triggered. If so, it publishes the
+// event to a pubsub queue.
+func (m *PubsubEventManager) Trigger(o *backend.Object, eventType EventType, extraEventAttr map[string]string) {
+	if m.publisher == nil {
+		return
+	}
+	if m.objectPrefix != "" && !strings.HasPrefix(o.Name, m.objectPrefix) {
+		return
+	}
+	switch eventType {
+	case EventFinalize:
+		if !m.notifyOn.Finalize {
+			return
+		}
+	case EventDelete:
+		if !m.notifyOn.Delete {
+			return
+		}
+	case EventMetadata:
+		if !m.notifyOn.MetadataUpdate {
+			return
+		}
+	case EventArchive:
+		if !m.notifyOn.Archive {
+			return
+		}
+	}
+	eventTime := time.Now().Format(time.RFC3339)
+	publishFunc := func() {
+		err := m.publish(o, eventType, eventTime, extraEventAttr)
+		if m.writer != nil {
+			if err != nil {
+				fmt.Fprintf(m.writer, "error publishing event: %v", err)
+			} else {
+				fmt.Fprintf(m.writer, "sent event %s for object %s\n", string(eventType), o.ID())
+			}
+		}
+	}
+	if m.publishSynchronously {
+		publishFunc()
+	} else {
+		go publishFunc()
+	}
+}
+
+func (m *PubsubEventManager) publish(o *backend.Object, eventType EventType, eventTime string, extraEventAttr map[string]string) error {
+	ctx := context.Background()
+	data, attributes, err := generateEvent(o, eventType, eventTime, extraEventAttr)
+	if err != nil {
+		return err
+	}
+	if r := m.publisher.Publish(ctx, &pubsub.Message{
+		Data:       data,
+		Attributes: attributes,
+	}); r != nil {
+		_, err = r.Get(ctx)
+		return err
+	}
+	return nil
+}
+
+// gcsEvent is the payload of a GCS event.  The description of the full object
+// can be found here:
+// https://cloud.google.com/storage/docs/json_api/v1/objects#resource-representations.
+type gcsEvent struct {
+	Kind            string            `json:"kind"`
+	ID              string            `json:"id"`
+	Name            string            `json:"name"`
+	Bucket          string            `json:"bucket"`
+	Generation      int64             `json:"generation,omitempty"`
+	ContentType     string            `json:"contentType"`
+	ContentEncoding string            `json:"contentEncoding,omitempty"`
+	Created         string            `json:"timeCreated,omitempty"`
+	Updated         string            `json:"updated,omitempty"`
+	StorageClass    string            `json:"storageClass"`
+	Size            string            `json:"size"`
+	MD5Hash         string            `json:"md5Hash,omitempty"`
+	CRC32c          string            `json:"crc32c,omitempty"`
+	MetaData        map[string]string `json:"metadata,omitempty"`
+}
+
+func generateEvent(o *backend.Object, eventType EventType, eventTime string, extraEventAttr map[string]string) ([]byte, map[string]string, error) {
+	payload := gcsEvent{
+		Kind:            "storage#object",
+		ID:              o.ID(),
+		Name:            o.Name,
+		Bucket:          o.BucketName,
+		Generation:      o.Generation,
+		ContentType:     o.ContentType,
+		ContentEncoding: o.ContentEncoding,
+		Created:         o.Created,
+		Updated:         o.Updated,
+		StorageClass:    "STANDARD",
+		Size:            strconv.Itoa(len(o.Content)),
+		MD5Hash:         o.Md5Hash,
+		CRC32c:          o.Crc32c,
+		MetaData:        o.Metadata,
+	}
+	attributes := map[string]string{
+		"bucketId":         o.BucketName,
+		"eventTime":        eventTime,
+		"eventType":        string(eventType),
+		"objectGeneration": strconv.FormatInt(o.Generation, 10),
+		"objectId":         o.Name,
+		"payloadFormat":    "JSON_API_V1",
+	}
+	for k, v := range extraEventAttr {
+		if _, exists := attributes[k]; exists {
+			return nil, nil, fmt.Errorf("cannot overwrite duplicate event attribute %s", k)
+		}
+		attributes[k] = v
+	}
+	data, err := json.Marshal(&payload)
+	if err != nil {
+		return nil, nil, err
+	}
+	return data, attributes, nil
+}


Test patch:
diff --git a/fakestorage/server_test.go b/fakestorage/server_test.go
index 2a34a42..0f111af 100644
--- a/fakestorage/server_test.go
+++ b/fakestorage/server_test.go
@@ -6,6 +6,11 @@ package fakestorage
 
 import (
 	"bytes"
+	"cloud.google.com/go/storage"
+	"context"
+	"github.com/fsouza/fake-gcs-server/internal/backend"
+	"github.com/fsouza/fake-gcs-server/internal/notification"
+	"github.com/stretchr/testify/assert"
 	"io"
 	"io/ioutil"
 	"net/http"
@@ -423,6 +428,234 @@ func TestCORSRequests(t *testing.T) {
 	}
 }
 
+type clientAction func(client *storage.Client) error
+
+func createObjectAction(obj Object) clientAction {
+	return func(client *storage.Client) error {
+		writer := client.Bucket(obj.BucketName).Object(obj.Name).NewWriter(context.TODO())
+		_, err := bytes.NewBuffer(obj.Content).WriteTo(writer)
+		if err != nil {
+			return err
+		}
+		return writer.Close()
+	}
+}
+
+func deleteObjectAction(obj Object) clientAction {
+	return func(client *storage.Client) error {
+		objHandle := client.Bucket(obj.BucketName).Object(obj.Name)
+		return objHandle.Delete(context.TODO())
+	}
+}
+
+func updateObjectAction(obj Object, newAttr storage.ObjectAttrsToUpdate) clientAction {
+	return func(client *storage.Client) error {
+		objHandle := client.Bucket(obj.BucketName).Object(obj.Name)
+		_, err := objHandle.Update(context.TODO(), newAttr)
+		return err
+	}
+}
+
+func TestServerEventNotification(t *testing.T) {
+	newMetadata := map[string]string{
+		"1-key": "1.1-value",
+		"2-key": "2-value",
+		"3-key": "3-value",
+	}
+	obj := Object{
+		ObjectAttrs: ObjectAttrs{BucketName: "some-bucket", Name: "files/txt/text-01.txt"},
+		Content:     []byte("something"),
+	}
+	tests := []struct {
+		name              string
+		expectedEvents    []fakeEvent
+		versioningEnabled bool
+		actions           []clientAction
+	}{
+		{
+			"None",
+			nil,
+			false,
+			nil,
+		},
+		{
+			"Finalize only",
+			[]fakeEvent{
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventFinalize,
+				},
+			},
+			false,
+			[]clientAction{createObjectAction(obj)},
+		},
+		{
+			"Delete",
+			[]fakeEvent{
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventFinalize,
+				},
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventDelete,
+				},
+			},
+			false,
+			[]clientAction{createObjectAction(obj), deleteObjectAction(obj)},
+		},
+		{
+			"Delete versioning enabled",
+			[]fakeEvent{
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventFinalize,
+				},
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventArchive,
+				},
+			},
+			true,
+			[]clientAction{createObjectAction(obj), deleteObjectAction(obj)},
+		},
+		{
+			"Metadata update",
+			[]fakeEvent{
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventFinalize,
+				},
+				{
+					obj:       fakeEventFieldsFromObjectWithMeta(obj, newMetadata),
+					eventType: notification.EventMetadata,
+				},
+			},
+			false,
+			[]clientAction{createObjectAction(obj), updateObjectAction(obj, storage.ObjectAttrsToUpdate{Metadata: newMetadata})},
+		},
+		{
+			"Metadata update versioning enabled",
+			[]fakeEvent{
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventFinalize,
+				},
+				{
+					obj:       fakeEventFieldsFromObjectWithMeta(obj, newMetadata),
+					eventType: notification.EventMetadata,
+				},
+			},
+			true,
+			[]clientAction{createObjectAction(obj), updateObjectAction(obj, storage.ObjectAttrsToUpdate{Metadata: newMetadata})},
+		},
+		{
+			"Finalize and overwrite",
+			[]fakeEvent{
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventFinalize,
+				},
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventDelete,
+				},
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventFinalize,
+				},
+			},
+			false,
+			[]clientAction{createObjectAction(obj), createObjectAction(obj)},
+		},
+		{
+			"Finalize and overwrite versioning enabled",
+			[]fakeEvent{
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventFinalize,
+				},
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventArchive,
+				},
+				{
+					obj:       fakeEventFieldsFromObject(obj),
+					eventType: notification.EventFinalize,
+				},
+			},
+			true,
+			[]clientAction{createObjectAction(obj), createObjectAction(obj)},
+		},
+	}
+
+	for _, test := range tests {
+		test := test
+		t.Run(test.name, func(t *testing.T) {
+			t.Parallel()
+			opts := Options{}
+			server, err := NewServerWithOptions(opts)
+			if err != nil {
+				t.Fatal(err)
+			}
+			eventManager := &fakeEventManager{}
+			server.eventManager = eventManager
+			err = server.backend.CreateBucket(obj.BucketName, test.versioningEnabled)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			for _, action := range test.actions {
+				if err := action(server.Client()); err != nil {
+					t.Fatal(err)
+				}
+			}
+
+			server.Stop()
+
+			assert.ElementsMatch(t, test.expectedEvents, eventManager.events)
+		})
+	}
+}
+
+type fakeEventFields struct {
+	BucketName string
+	Name       string
+	Content    []byte
+	Metadata   map[string]string
+}
+
+func fakeEventFieldsFromObject(obj Object) fakeEventFields {
+	return fakeEventFields{
+		BucketName: obj.BucketName,
+		Name:       obj.Name,
+		Content:    obj.Content,
+		Metadata:   obj.Metadata,
+	}
+}
+
+func fakeEventFieldsFromObjectWithMeta(obj Object, meta map[string]string) fakeEventFields {
+	fields := fakeEventFieldsFromObject(obj)
+	fields.Metadata = meta
+	return fields
+}
+
+type fakeEvent struct {
+	obj       fakeEventFields
+	eventType notification.EventType
+}
+
+type fakeEventManager struct {
+	events []fakeEvent
+}
+
+func (m *fakeEventManager) Trigger(o *backend.Object, eventType notification.EventType, extraEventAttr map[string]string) {
+	m.events = append(m.events, fakeEvent{
+		obj:       fakeEventFieldsFromObject(fromBackendObjects([]backend.Object{*o})[0]),
+		eventType: eventType,
+	})
+}
+
 func runServersTest(t *testing.T, objs []Object, fn func(*testing.T, *Server)) {
 	var testScenarios = []struct {
 		name    string
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index adc33d6..911cc08 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -5,6 +5,7 @@
 package config
 
 import (
+	"github.com/fsouza/fake-gcs-server/internal/notification"
 	"testing"
 
 	"github.com/fsouza/fake-gcs-server/fakestorage"
@@ -32,6 +33,10 @@ func TestLoadConfig(t *testing.T) {
 				"-port", "443",
 				"-data", "/var/gcs",
 				"-scheme", "http",
+				"-event.pubsub-project-id", "test-project",
+				"-event.pubsub-topic", "gcs-events",
+				"-event.object-prefix", "uploads/",
+				"-event.list", "finalize,delete,metadataUpdate,archive",
 			},
 			expectedConfig: Config{
 				Seed:               "/var/gcs",
@@ -43,6 +48,12 @@ func TestLoadConfig(t *testing.T) {
 				host:               "127.0.0.1",
 				port:               443,
 				scheme:             "http",
+				event: EventConfig{
+					pubsubProjectID: "test-project",
+					pubsubTopic:     "gcs-events",
+					prefix:          "uploads/",
+					list:            []string{"finalize", "delete", "metadataUpdate", "archive"},
+				},
 			},
 		},
 		{
@@ -57,6 +68,9 @@ func TestLoadConfig(t *testing.T) {
 				host:               "0.0.0.0",
 				port:               4443,
 				scheme:             "https",
+				event: EventConfig{
+					list: []string{"finalize"},
+				},
 			},
 		},
 		{
@@ -79,6 +93,21 @@ func TestLoadConfig(t *testing.T) {
 			args:      []string{"-backend", "filesystem", "-filesystem-root", ""},
 			expectErr: true,
 		},
+		{
+			name:      "missing event pubsub project ID",
+			args:      []string{"-event.pubsub-topic", "gcs-events"},
+			expectErr: true,
+		},
+		{
+			name:      "missing event pubsub topic",
+			args:      []string{"-event.pubsub-project-id", "test-project"},
+			expectErr: true,
+		},
+		{
+			name:      "invalid events",
+			args:      []string{"-event.list", "invalid,stuff", "-event.pubsub-topic", "gcs-events", "-event.pubsub-project-id", "test-project"},
+			expectErr: true,
+		},
 	}
 	for _, test := range tests {
 		test := test
@@ -86,11 +115,11 @@ func TestLoadConfig(t *testing.T) {
 			t.Parallel()
 			cfg, err := Load(test.args)
 			if err != nil && !test.expectErr {
-				t.Fatalf("uexpected non-nil error: %v", err)
+				t.Fatalf("unexpected non-nil error: %v", err)
 			} else if err == nil && test.expectErr {
 				t.Fatal("unexpected <nil> error")
 			}
-			if diff := cmp.Diff(cfg, test.expectedConfig, cmp.AllowUnexported(Config{})); !test.expectErr && diff != "" {
+			if diff := cmp.Diff(cfg, test.expectedConfig, cmp.AllowUnexported(Config{}, EventConfig{})); !test.expectErr && diff != "" {
 				t.Errorf("wrong config returned\nwant %#v\ngot  %#v\ndiff: %v", test.expectedConfig, cfg, diff)
 			}
 		})
@@ -113,6 +142,12 @@ func TestToFakeGcsOptions(t *testing.T) {
 				externalURL: "https://myhost.example.com:8443",
 				host:        "0.0.0.0",
 				port:        443,
+				event: EventConfig{
+					pubsubProjectID: "test-project",
+					pubsubTopic:     "gcs-events",
+					prefix:          "uploads/",
+					list:            []string{"finalize", "delete"},
+				},
 			},
 			fakestorage.Options{
 				StorageRoot: "/tmp/something",
@@ -120,6 +155,16 @@ func TestToFakeGcsOptions(t *testing.T) {
 				ExternalURL: "https://myhost.example.com:8443",
 				Host:        "0.0.0.0",
 				Port:        443,
+				EventOptions: notification.EventManagerOptions{
+					ProjectID:    "test-project",
+					TopicName:    "gcs-events",
+					ObjectPrefix: "uploads/",
+					NotifyOn: notification.EventNotificationOptions{
+						Finalize:       true,
+						Delete:         true,
+						MetadataUpdate: false,
+					},
+				},
 			},
 		},
 		{
diff --git a/internal/notification/event_test.go b/internal/notification/event_test.go
new file mode 100644
index 0000000..641abd3
--- /dev/null
+++ b/internal/notification/event_test.go
@@ -0,0 +1,147 @@
+// Copyright 2017 Francisco Souza. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package notification
+
+import (
+	"cloud.google.com/go/pubsub"
+	"context"
+	"encoding/json"
+	"github.com/fsouza/fake-gcs-server/internal/backend"
+	"reflect"
+	"strconv"
+	"testing"
+)
+
+type mockPublisher struct {
+	lastMessage *pubsub.Message
+}
+
+func (m *mockPublisher) Publish(ctx context.Context, msg *pubsub.Message) *pubsub.PublishResult {
+	m.lastMessage = msg
+	return nil
+}
+
+func TestPubsubEventManager_Trigger(t *testing.T) {
+	newMetadata := map[string]string{
+		"1-key": "1.1-value",
+		"2-key": "2-value",
+		"3-key": "3-value",
+	}
+	tests := []struct {
+		name      string
+		notifyOn  EventNotificationOptions
+		eventType string
+		prefix    string
+		metadata  map[string]string
+	}{
+		{
+			"None",
+			EventNotificationOptions{},
+			"",
+			"",
+			nil,
+		},
+		{
+			"Finalize enabled, no prefix",
+			EventNotificationOptions{
+				Finalize: true,
+			},
+			string(EventFinalize),
+			"",
+			nil,
+		},
+		{
+			"Finalize enabled, matching prefix",
+			EventNotificationOptions{
+				Finalize: true,
+			},
+			string(EventFinalize),
+			"files/",
+			nil,
+		},
+		{
+			"Finalize enabled, non-matching prefix",
+			EventNotificationOptions{
+				Finalize: true,
+			},
+			"",
+			"uploads/",
+			nil,
+		},
+		{
+			"Delete enabled",
+			EventNotificationOptions{
+				Delete: true,
+			},
+			string(EventDelete),
+			"",
+			nil,
+		},
+		{
+			"Metadata update enabled",
+			EventNotificationOptions{
+				MetadataUpdate: true,
+			},
+			string(EventMetadata),
+			"",
+			newMetadata,
+		},
+	}
+
+	for _, test := range tests {
+		test := test
+		t.Run(test.name, func(t *testing.T) {
+			t.Parallel()
+			obj := backend.Object{ObjectAttrs: backend.ObjectAttrs{BucketName: "some-bucket", Name: "files/txt/text-01.txt"}, Content: []byte("something")}
+			eventManager := PubsubEventManager{
+				notifyOn:     test.notifyOn,
+				objectPrefix: test.prefix,
+			}
+			publisher := &mockPublisher{}
+			eventManager.publisher = publisher
+			eventManager.publishSynchronously = true
+
+			eventManager.Trigger(&obj, EventFinalize, nil)
+			eventManager.Trigger(&obj, EventDelete, nil)
+			eventManager.Trigger(&obj, EventArchive, nil)
+			if test.metadata != nil {
+				obj.Metadata = test.metadata
+			}
+			eventManager.Trigger(&obj, EventMetadata, nil)
+
+			receivedMessage := publisher.lastMessage
+			switch test.eventType {
+			case "":
+				if receivedMessage != nil {
+					t.Errorf("expecting no event but got %v", receivedMessage)
+				}
+			default:
+				if receivedMessage == nil || receivedMessage.Attributes == nil {
+					t.Errorf("expecting event %q but got %v", test.eventType, receivedMessage)
+				} else {
+					if test.eventType != receivedMessage.Attributes["eventType"] {
+						t.Errorf("wrong event type\nwant %q\ngot %q", test.eventType, receivedMessage.Attributes["eventType"])
+					}
+					var receivedEvent gcsEvent
+					if err := json.Unmarshal(receivedMessage.Data, &receivedEvent); err != nil {
+						t.Errorf("invalid event payload: %v", err)
+					}
+					if obj.BucketName != receivedEvent.Bucket {
+						t.Errorf("wrong bucket on object\nwant %q\ngot %q", obj.BucketName, receivedEvent.Bucket)
+					}
+					if obj.Name != receivedEvent.Name {
+						t.Errorf("wrong objectc name\nwant %q\ngot %q", obj.Name, receivedEvent.Name)
+					}
+					if strconv.Itoa(len(obj.Content)) != receivedEvent.Size {
+						t.Errorf("wrong object size\nwant %q\ngot %q", strconv.Itoa(len(obj.Content)), receivedEvent.Size)
+					}
+					if !reflect.DeepEqual(test.metadata, receivedEvent.MetaData) {
+						t.Errorf("wrong object metadata\nwant %q\ngot %q", test.metadata, receivedEvent.MetaData)
+					}
+				}
+			}
+		})
+	}
+}