Skip to content

Commit 518886a

Browse files
committed
Cache busting
1 parent 3f90b8c commit 518886a

File tree

2 files changed

+221
-27
lines changed

2 files changed

+221
-27
lines changed

pkg/handler3/handler.go

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,12 @@ const (
4949
subTypeJSON = "json"
5050
)
5151

52+
type OpenAPIV3Discovery struct {
53+
Paths map[string]string
54+
}
55+
5256
// OpenAPIService is the service responsible for serving OpenAPI spec. It has
5357
// 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
5558
type OpenAPIService struct {
5659
// rwMutex protects All members of this service.
5760
rwMutex sync.RWMutex
@@ -74,6 +77,14 @@ func init() {
7477
mime.AddExtensionType(".gz", mimePbGz)
7578
}
7679

80+
// unquoteEtag removes the string quotes around an ETag
81+
func unquoteETag(etag string) string {
82+
if len(etag) < 2 {
83+
return etag
84+
}
85+
return etag[1 : len(etag)-1]
86+
}
87+
7788
func computeETag(data []byte) string {
7889
return fmt.Sprintf("\"%X\"", sha512.Sum512(data))
7990
}
@@ -96,10 +107,15 @@ func (o *OpenAPIService) getGroupBytes() ([]byte, error) {
96107
}
97108

98109
sort.Strings(keys)
99-
group := make(map[string][]string)
100-
group["Paths"] = keys
101-
102-
j, err := json.Marshal(group)
110+
discovery := &OpenAPIV3Discovery{Paths: make(map[string]string)}
111+
for k, v := range o.v3Schema {
112+
_, etag, err := v.jsonCache.Get()
113+
if err != nil {
114+
return nil, err
115+
}
116+
discovery.Paths[k] = "openapi/v3/" + k + "?hash=" + unquoteETag(etag)
117+
}
118+
j, err := json.Marshal(discovery)
103119
if err != nil {
104120
return nil, err
105121
}
@@ -117,7 +133,11 @@ func (o *OpenAPIService) getSingleGroupBytes(getType string, group string) ([]by
117133
specBytes, etag, err := v.jsonCache.Get()
118134
return specBytes, etag, v.lastModified, err
119135
} else if getType == subTypeProtobuf {
120-
specPb, etag, err := v.pbCache.Get()
136+
_, etag, err := v.jsonCache.Get()
137+
if err != nil {
138+
return nil, "", v.lastModified, err
139+
}
140+
specPb, _, err := v.pbCache.Get()
121141
return specPb, etag, v.lastModified, err
122142
}
123143
return nil, "", time.Now(), fmt.Errorf("Invalid accept clause %s", getType)
@@ -127,15 +147,10 @@ func (o *OpenAPIService) UpdateGroupVersion(group string, openapi *spec3.OpenAPI
127147
o.rwMutex.Lock()
128148
defer o.rwMutex.Unlock()
129149

130-
specBytes, err := json.Marshal(openapi)
131-
if err != nil {
132-
return err
133-
}
134-
135150
if _, ok := o.v3Schema[group]; !ok {
136151
o.v3Schema[group] = &OpenAPIV3Group{}
137152
}
138-
return o.v3Schema[group].UpdateSpec(specBytes)
153+
return o.v3Schema[group].UpdateSpec(openapi)
139154
}
140155

141156
func (o *OpenAPIService) DeleteGroupVersion(group string) {
@@ -157,6 +172,24 @@ func (o *OpenAPIService) HandleDiscovery(w http.ResponseWriter, r *http.Request)
157172
http.ServeContent(w, r, "/openapi/v3", time.Now(), bytes.NewReader(data))
158173
}
159174

175+
func ApplyCacheBusting(w http.ResponseWriter, r *http.Request, etag string) {
176+
hash := r.URL.Query().Get("hash")
177+
// Only apply cache busting on requests where the hash is provided.
178+
if hash != "" {
179+
180+
// 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.
181+
w.Header().Set("Vary", "*")
182+
183+
if hash == unquoteETag(etag) {
184+
// Set the Expires directive to one year from the request, effectively indicating that the cache never expires.
185+
w.Header().Set("Expires", time.Now().AddDate(1, 0, 0).Format(time.RFC1123))
186+
w.Header().Set("Cache-Control", "public, immutable")
187+
} else {
188+
http.Redirect(w, r, r.URL.Path+"?hash="+unquoteETag(etag), 301)
189+
}
190+
}
191+
}
192+
160193
func (o *OpenAPIService) HandleGroupVersion(w http.ResponseWriter, r *http.Request) {
161194
url := strings.SplitAfterN(r.URL.Path, "/", 4)
162195
group := url[3]
@@ -193,6 +226,8 @@ func (o *OpenAPIService) HandleGroupVersion(w http.ResponseWriter, r *http.Reque
193226
return
194227
}
195228
w.Header().Set("Etag", etag)
229+
230+
ApplyCacheBusting(w, r, etag)
196231
http.ServeContent(w, r, "", lastModified, bytes.NewReader(data))
197232
return
198233
}
@@ -207,15 +242,23 @@ func (o *OpenAPIService) RegisterOpenAPIV3VersionedService(servePath string, han
207242
return nil
208243
}
209244

210-
func (o *OpenAPIV3Group) UpdateSpec(specBytes []byte) (err error) {
245+
func (o *OpenAPIV3Group) UpdateSpec(openapi *spec3.OpenAPI) (err error) {
211246
o.rwMutex.Lock()
212247
defer o.rwMutex.Unlock()
213248

214249
o.pbCache = o.pbCache.New(func() ([]byte, error) {
215-
return ToV3ProtoBinary(specBytes)
250+
json, _, err := o.jsonCache.Get()
251+
if err != nil {
252+
return nil, err
253+
}
254+
return ToV3ProtoBinary(json)
216255
})
217256

218257
o.jsonCache = o.jsonCache.New(func() ([]byte, error) {
258+
specBytes, err := json.Marshal(openapi)
259+
if err != nil {
260+
return nil, err
261+
}
219262
return specBytes, nil
220263
})
221264
o.lastModified = time.Now()

pkg/handler3/handler_test.go

Lines changed: 164 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
/*
2-
Copyright 2021 The Kubernetes Authors.
2+
Copyright 2021 The Kubernetes Authors.
33
4-
Licensed under the Apache License, Version 2.0 (the "License");
5-
you may not use this file except in compliance with the License.
6-
You may obtain a copy of the License at
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
77
8-
http://www.apache.org/licenses/LICENSE-2.0
8+
http://www.apache.org/licenses/LICENSE-2.0
99
10-
Unless required by applicable law or agreed to in writing, software
11-
distributed under the License is distributed on an "AS IS" BASIS,
12-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13-
See the License for the specific language governing permissions and
14-
limitations under the License.
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
1515
*/
1616

1717
package handler3
@@ -23,13 +23,12 @@ import (
2323
"net/http/httptest"
2424
"reflect"
2525
"testing"
26+
"time"
2627

2728
"encoding/json"
2829
"k8s.io/kube-openapi/pkg/spec3"
2930
)
3031

31-
var returnedGroupVersionListJSON = []byte(`{"Paths":["apis/apps/v1"]}`)
32-
3332
var returnedOpenAPI = []byte(`{
3433
"openapi": "3.0",
3534
"info": {
@@ -45,6 +44,9 @@ func TestRegisterOpenAPIVersionedService(t *testing.T) {
4544
t.Errorf("%v", err)
4645
}
4746
compactOpenAPI := buffer.Bytes()
47+
var hash = unquoteETag(computeETag(compactOpenAPI))
48+
49+
var returnedGroupVersionListJSON = []byte(`{"Paths":{"apis/apps/v1":"openapi/v3/apis/apps/v1?hash=` + hash + `"}}`)
4850

4951
json.Unmarshal(compactOpenAPI, &s)
5052

@@ -54,7 +56,6 @@ func TestRegisterOpenAPIVersionedService(t *testing.T) {
5456
}
5557

5658
returnedPb, err := ToV3ProtoBinary(compactOpenAPI)
57-
_ = returnedPb
5859

5960
if err != nil {
6061
t.Fatalf("Unexpected error in preparing returnedPb: %v", err)
@@ -120,3 +121,153 @@ func TestRegisterOpenAPIVersionedService(t *testing.T) {
120121
}
121122
}
122123
}
124+
125+
func TestCacheBusting(t *testing.T) {
126+
var s *spec3.OpenAPI
127+
buffer := new(bytes.Buffer)
128+
if err := json.Compact(buffer, returnedOpenAPI); err != nil {
129+
t.Errorf("%v", err)
130+
}
131+
compactOpenAPI := buffer.Bytes()
132+
var hash = unquoteETag(computeETag(compactOpenAPI))
133+
134+
json.Unmarshal(compactOpenAPI, &s)
135+
136+
returnedJSON, err := json.Marshal(s)
137+
if err != nil {
138+
t.Fatalf("Unexpected error in preparing returnedJSON: %v", err)
139+
}
140+
141+
returnedPb, err := ToV3ProtoBinary(compactOpenAPI)
142+
143+
if err != nil {
144+
t.Fatalf("Unexpected error in preparing returnedPb: %v", err)
145+
}
146+
147+
mux := http.NewServeMux()
148+
o, err := NewOpenAPIService(nil)
149+
if err != nil {
150+
t.Fatal(err)
151+
}
152+
153+
mux.Handle("/openapi/v3", http.HandlerFunc(o.HandleDiscovery))
154+
mux.Handle("/openapi/v3/apis/apps/v1", http.HandlerFunc(o.HandleGroupVersion))
155+
156+
o.UpdateGroupVersion("apis/apps/v1", s)
157+
158+
server := httptest.NewServer(mux)
159+
defer server.Close()
160+
client := server.Client()
161+
162+
tcs := []struct {
163+
acceptHeader string
164+
respStatus int
165+
urlPath string
166+
respBody []byte
167+
expectedHash string
168+
cacheControl string
169+
}{
170+
// Correct hash should yield the proper expiry and Cache Control headers
171+
{"application/json",
172+
200,
173+
"openapi/v3/apis/apps/v1?hash=" + hash,
174+
returnedJSON,
175+
hash,
176+
"public, immutable",
177+
},
178+
{"application/[email protected]+protobuf",
179+
200,
180+
"openapi/v3/apis/apps/v1?hash=" + hash,
181+
returnedPb,
182+
hash,
183+
"public, immutable",
184+
},
185+
// Incorrect hash should redirect to the page with the correct hash
186+
{"application/json",
187+
200,
188+
"openapi/v3/apis/apps/v1?hash=" + "OUTDATEDHASH",
189+
returnedJSON,
190+
hash,
191+
"public, immutable",
192+
},
193+
{"application/[email protected]+protobuf",
194+
200,
195+
"openapi/v3/apis/apps/v1?hash=" + "OUTDATEDHASH",
196+
returnedPb,
197+
hash,
198+
"public, immutable",
199+
},
200+
201+
// No hash should not return Cache Control information
202+
{"application/json",
203+
200,
204+
"openapi/v3/apis/apps/v1",
205+
returnedJSON,
206+
"",
207+
"",
208+
},
209+
{"application/[email protected]+protobuf",
210+
200,
211+
"openapi/v3/apis/apps/v1",
212+
returnedPb,
213+
"",
214+
"",
215+
},
216+
}
217+
218+
for _, tc := range tcs {
219+
req, err := http.NewRequest("GET", server.URL+"/"+tc.urlPath, nil)
220+
if err != nil {
221+
t.Errorf("Accept: %v: Unexpected error in creating new request: %v", tc.acceptHeader, err)
222+
}
223+
224+
req.Header.Add("Accept", tc.acceptHeader)
225+
resp, err := client.Do(req)
226+
if err != nil {
227+
t.Errorf("Accept: %v: Unexpected error in serving HTTP request: %v", tc.acceptHeader, err)
228+
}
229+
230+
if resp.StatusCode != 200 {
231+
t.Errorf("Accept: Unexpected response status code, want: %v, got: %v", 200, resp.StatusCode)
232+
}
233+
234+
if cacheControl := resp.Header.Get("Cache-Control"); cacheControl != tc.cacheControl {
235+
t.Errorf("Expected Cache Control %v, got %v", tc.cacheControl, cacheControl)
236+
}
237+
238+
if tc.expectedHash != "" {
239+
if hash := resp.Request.URL.Query()["hash"]; hash[0] != tc.expectedHash {
240+
t.Errorf("Expected Hash: %s, got %s", tc.expectedHash, hash[0])
241+
}
242+
243+
expires := resp.Header.Get("Expires")
244+
parsedTime, err := time.Parse(time.RFC1123, expires)
245+
if err != nil {
246+
t.Errorf("Could not parse cache expiry %v", expires)
247+
}
248+
249+
difference := parsedTime.Sub(time.Now()).Hours()
250+
if difference <= 0 {
251+
t.Errorf("Expected cache expiry to be in the future")
252+
}
253+
} else {
254+
hash := resp.Request.URL.Query()["hash"]
255+
if len(hash) != 0 {
256+
t.Errorf("Expect no redirect and empty hash if the hash is not provide")
257+
}
258+
expires := resp.Header.Get("Expires")
259+
if expires != "" {
260+
t.Errorf("Expected an empty Expiry if hash is not provided, got %v", expires)
261+
}
262+
}
263+
264+
defer resp.Body.Close()
265+
body, err := ioutil.ReadAll(resp.Body)
266+
if err != nil {
267+
t.Errorf("Accept: %v: Unexpected error in reading response body: %v", tc.acceptHeader, err)
268+
}
269+
if !reflect.DeepEqual(body, tc.respBody) {
270+
t.Errorf("Accept: %v: Response body mismatches, \nwant: %s, \ngot: %s", tc.acceptHeader, string(tc.respBody), string(body))
271+
}
272+
}
273+
}

0 commit comments

Comments
 (0)