@@ -23,7 +23,10 @@ import (
2323	"fmt" 
2424	"mime" 
2525	"net/http" 
26+ 	"net/url" 
27+ 	"path" 
2628	"sort" 
29+ 	"strconv" 
2730	"strings" 
2831	"sync" 
2932	"time" 
@@ -49,9 +52,20 @@ const (
4952	subTypeJSON      =  "json" 
5053)
5154
55+ // OpenAPIV3Discovery is the format of the Discovery document for OpenAPI V3 
56+ // It maps Discovery paths to their corresponding URLs with a hash parameter included 
57+ type  OpenAPIV3Discovery  struct  {
58+ 	Paths  map [string ]OpenAPIV3DiscoveryGroupVersion 
59+ }
60+ 
61+ // OpenAPIV3DiscoveryGroupVersion includes information about a group version and URL 
62+ // for accessing the OpenAPI. The URL includes a hash parameter to support client side caching 
63+ type  OpenAPIV3DiscoveryGroupVersion  struct  {
64+ 	URL  string 
65+ }
66+ 
5267// OpenAPIService is the service responsible for serving OpenAPI spec. It has 
5368// the ability to safely change the spec while serving it. 
54- // OpenAPI V3 currently does not use the lazy marshaling strategy that OpenAPI V2 is using 
5569type  OpenAPIService  struct  {
5670	// rwMutex protects All members of this service. 
5771	rwMutex       sync.RWMutex 
@@ -66,6 +80,7 @@ type OpenAPIV3Group struct {
6680
6781	pbCache    handler.HandlerCache 
6882	jsonCache  handler.HandlerCache 
83+ 	etagCache  handler.HandlerCache 
6984}
7085
7186func  init () {
@@ -75,7 +90,18 @@ func init() {
7590}
7691
7792func  computeETag (data  []byte ) string  {
78- 	return  fmt .Sprintf ("\" %X\" " , sha512 .Sum512 (data ))
93+ 	if  data  ==  nil  {
94+ 		return  "" 
95+ 	}
96+ 	return  fmt .Sprintf ("%X" , sha512 .Sum512 (data ))
97+ }
98+ 
99+ func  constructURL (gvString , etag  string ) string  {
100+ 	u  :=  url.URL {Path : path .Join ("/openapi/v3" , gvString )}
101+ 	query  :=  url.Values {}
102+ 	query .Set ("hash" , etag )
103+ 	u .RawQuery  =  query .Encode ()
104+ 	return  u .String ()
79105}
80106
81107// NewOpenAPIService builds an OpenAPIService starting with the given spec. 
@@ -96,10 +122,17 @@ func (o *OpenAPIService) getGroupBytes() ([]byte, error) {
96122	}
97123
98124	sort .Strings (keys )
99- 	group  :=  make (map [string ][]string )
100- 	group ["Paths" ] =  keys 
101- 
102- 	j , err  :=  json .Marshal (group )
125+ 	discovery  :=  & OpenAPIV3Discovery {Paths : make (map [string ]OpenAPIV3DiscoveryGroupVersion )}
126+ 	for  gvString , groupVersion  :=  range  o .v3Schema  {
127+ 		etagBytes , err  :=  groupVersion .etagCache .Get ()
128+ 		if  err  !=  nil  {
129+ 			return  nil , err 
130+ 		}
131+ 		discovery .Paths [gvString ] =  OpenAPIV3DiscoveryGroupVersion {
132+ 			URL : constructURL (gvString , string (etagBytes )),
133+ 		}
134+ 	}
135+ 	j , err  :=  json .Marshal (discovery )
103136	if  err  !=  nil  {
104137		return  nil , err 
105138	}
@@ -114,11 +147,19 @@ func (o *OpenAPIService) getSingleGroupBytes(getType string, group string) ([]by
114147		return  nil , "" , time .Now (), fmt .Errorf ("Cannot find CRD group %s" , group )
115148	}
116149	if  getType  ==  subTypeJSON  {
117- 		specBytes , etag , err  :=  v .jsonCache .Get ()
118- 		return  specBytes , etag , v .lastModified , err 
150+ 		specBytes , err  :=  v .jsonCache .Get ()
151+ 		if  err  !=  nil  {
152+ 			return  nil , "" , v .lastModified , err 
153+ 		}
154+ 		etagBytes , err  :=  v .etagCache .Get ()
155+ 		return  specBytes , string (etagBytes ), v .lastModified , err 
119156	} else  if  getType  ==  subTypeProtobuf  {
120- 		specPb , etag , err  :=  v .pbCache .Get ()
121- 		return  specPb , etag , v .lastModified , err 
157+ 		specPb , err  :=  v .pbCache .Get ()
158+ 		if  err  !=  nil  {
159+ 			return  nil , "" , v .lastModified , err 
160+ 		}
161+ 		etagBytes , err  :=  v .etagCache .Get ()
162+ 		return  specPb , string (etagBytes ), v .lastModified , err 
122163	}
123164	return  nil , "" , time .Now (), fmt .Errorf ("Invalid accept clause %s" , getType )
124165}
@@ -127,15 +168,10 @@ func (o *OpenAPIService) UpdateGroupVersion(group string, openapi *spec3.OpenAPI
127168	o .rwMutex .Lock ()
128169	defer  o .rwMutex .Unlock ()
129170
130- 	specBytes , err  :=  json .Marshal (openapi )
131- 	if  err  !=  nil  {
132- 		return  err 
133- 	}
134- 
135171	if  _ , ok  :=  o .v3Schema [group ]; ! ok  {
136172		o .v3Schema [group ] =  & OpenAPIV3Group {}
137173	}
138- 	return  o .v3Schema [group ].UpdateSpec (specBytes )
174+ 	return  o .v3Schema [group ].UpdateSpec (openapi )
139175}
140176
141177func  (o  * OpenAPIService ) DeleteGroupVersion (group  string ) {
@@ -157,6 +193,21 @@ func (o *OpenAPIService) HandleDiscovery(w http.ResponseWriter, r *http.Request)
157193	http .ServeContent (w , r , "/openapi/v3" , time .Now (), bytes .NewReader (data ))
158194}
159195
196+ func  applyCacheBusting (w  http.ResponseWriter , r  * http.Request , etag  string ) {
197+ 	hash  :=  r .URL .Query ().Get ("hash" )
198+ 	// Only apply cache busting on requests where the hash is provided. 
199+ 	if  hash  !=  ""  {
200+ 		// The Vary header is required because the Accept header can change the contents returned. This prevents clients from caching protobuf as JSON and vice versa. 
201+ 		w .Header ().Set ("Vary" , "*" )
202+ 
203+ 		if  hash  ==  etag  {
204+ 			// Set the Expires directive to one year from the request, effectively indicating that the cache never expires. 
205+ 			w .Header ().Set ("Expires" , time .Now ().AddDate (1 , 0 , 0 ).Format (time .RFC1123 ))
206+ 			w .Header ().Set ("Cache-Control" , "public, immutable" )
207+ 		}
208+ 	}
209+ }
210+ 
160211func  (o  * OpenAPIService ) HandleGroupVersion (w  http.ResponseWriter , r  * http.Request ) {
161212	url  :=  strings .SplitAfterN (r .URL .Path , "/" , 4 )
162213	group  :=  url [3 ]
@@ -192,7 +243,27 @@ func (o *OpenAPIService) HandleGroupVersion(w http.ResponseWriter, r *http.Reque
192243			if  err  !=  nil  {
193244				return 
194245			}
195- 			w .Header ().Set ("Etag" , etag )
246+ 			// ETag must be enclosed in double quotes: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag 
247+ 			w .Header ().Set ("Etag" , strconv .Quote (etag ))
248+ 
249+ 			if  hash  :=  r .URL .Query ().Get ("hash" ); hash  !=  ""  {
250+ 				if  hash  !=  etag  {
251+ 					u  :=  constructURL (group , etag )
252+ 					http .Redirect (w , r , u , 301 )
253+ 					return 
254+ 				}
255+ 				// The Vary header is required because the Accept header can 
256+ 				// change the contents returned. This prevents clients from caching 
257+ 				// protobuf as JSON and vice versa. 
258+ 				w .Header ().Set ("Vary" , "Accept" )
259+ 
260+ 				// Only set these headers when a hash is given. 
261+ 				w .Header ().Set ("Cache-Control" , "public, immutable" )
262+ 				// Set the Expires directive to the maximum value of one year from the request, 
263+ 				// effectively indicating that the cache never expires. 
264+ 				w .Header ().Set ("Expires" , time .Now ().AddDate (1 , 0 , 0 ).Format (time .RFC1123 ))
265+ 			}
266+ 			applyCacheBusting (w , r , etag )
196267			http .ServeContent (w , r , "" , lastModified , bytes .NewReader (data ))
197268			return 
198269		}
@@ -207,16 +278,28 @@ func (o *OpenAPIService) RegisterOpenAPIV3VersionedService(servePath string, han
207278	return  nil 
208279}
209280
210- func  (o  * OpenAPIV3Group ) UpdateSpec (specBytes  [] byte ) (err  error ) {
281+ func  (o  * OpenAPIV3Group ) UpdateSpec (openapi   * spec3. OpenAPI ) (err  error ) {
211282	o .rwMutex .Lock ()
212283	defer  o .rwMutex .Unlock ()
213284
285+ 	o .jsonCache  =  o .jsonCache .New (func () ([]byte , error ) {
286+ 		return  json .Marshal (openapi )
287+ 	})
214288	o .pbCache  =  o .pbCache .New (func () ([]byte , error ) {
215- 		return  ToV3ProtoBinary (specBytes )
289+ 		json , err  :=  o .jsonCache .Get ()
290+ 		if  err  !=  nil  {
291+ 			return  nil , err 
292+ 		}
293+ 		return  ToV3ProtoBinary (json )
216294	})
217- 
218- 	o .jsonCache  =  o .jsonCache .New (func () ([]byte , error ) {
219- 		return  specBytes , nil 
295+ 	// TODO: This forces a json marshal of corresponding group-versions. 
296+ 	// We should look to replace this with a faster hashing mechanism. 
297+ 	o .etagCache  =  o .etagCache .New (func () ([]byte , error ) {
298+ 		json , err  :=  o .jsonCache .Get ()
299+ 		if  err  !=  nil  {
300+ 			return  nil , err 
301+ 		}
302+ 		return  []byte (computeETag (json )), nil 
220303	})
221304	o .lastModified  =  time .Now ()
222305	return  nil 
0 commit comments