Skip to content

Commit 9593551

Browse files
feat(syntax): use SAM-provided schema instead of goformation #3163
Problem: The http resource fetcher class did not provide an option to give an eTag and attempt to fetch fresh content. Solution: Add a function that users can call with an eTag, it will then return the response which would be new content with a new eTag, OR if the given eTag is the same as the remote it will give back undefined content. Problem: For our doctype suport we use different schemas for cfn and sam. But now there is an all-in-one sam schema that works for cfn as well. Solution: Have cfn and sam use the new sam schema. This requires us to update our url content fetching + caching mechanisms to use e-tags to determine stale content instead of using versions as we were previously doing. * test: integ test for getDefaultSchemas() happy path The getDefaultSchemas() function downloads a schema if it doesn't exist yet in cache. This test uses the telemetry data to determine if the cache is used when a request for a schema is made, otherwise downloading it. - Additionally, this updates the schema assertion function to allow the user to choose the event occurence they want to assert against. Signed-off-by: Nikolas Komonen <[email protected]>
1 parent b539eb0 commit 9593551

File tree

4 files changed

+225
-34
lines changed

4 files changed

+225
-34
lines changed

src/integrationTest/schema/schema.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import { getDefaultSchemas, samAndCfnSchemaUrl } from '../../shared/schemas'
7+
import { FakeExtensionContext } from '../../test/fakeExtensionContext'
68
import {
79
getCITestSchemas,
810
JSONObject,
@@ -12,6 +14,7 @@ import {
1214
assertRef,
1315
assertDefinition,
1416
} from '../../test/shared/schema/testUtils'
17+
import { assertTelemetry } from '../../test/testUtil'
1518

1619
describe('Sam Schema Regression', function () {
1720
let samSchema: JSONObject
@@ -61,3 +64,48 @@ describe('Sam Schema Regression', function () {
6164
assertDefinitionProperty(samSchema, 'AWS::Serverless::Api.Auth', 'AddDefaultAuthorizerToCorsPreflight')
6265
})
6366
})
67+
68+
describe('getDefaultSchemas()', () => {
69+
let extensionContext: FakeExtensionContext
70+
71+
beforeEach(async () => {
72+
extensionContext = await FakeExtensionContext.create()
73+
})
74+
75+
it('uses cache on subsequent request for CFN/SAM schema', async () => {
76+
await getDefaultSchemas(extensionContext)
77+
78+
// IMPORTANT: Since CFN and SAM use the same schema, the order their schema is retrieved is irrelevant
79+
80+
// Schema is downloaded on initial retrieval
81+
assertTelemetry(
82+
'toolkit_getExternalResource',
83+
{ url: samAndCfnSchemaUrl, passive: true, result: 'Succeeded' },
84+
0
85+
)
86+
// Schema is retrieved from cache on subsequent retrieval
87+
assertTelemetry(
88+
'toolkit_getExternalResource',
89+
{ url: samAndCfnSchemaUrl, passive: true, result: 'Cancelled', reason: 'Cache hit' },
90+
1
91+
)
92+
})
93+
94+
it('uses cache for all requests on second function call', async () => {
95+
await getDefaultSchemas(extensionContext)
96+
// Call a second time
97+
await getDefaultSchemas(extensionContext)
98+
99+
// Only cache is used
100+
assertTelemetry(
101+
'toolkit_getExternalResource',
102+
{ url: samAndCfnSchemaUrl, passive: true, result: 'Cancelled', reason: 'Cache hit' },
103+
2
104+
)
105+
assertTelemetry(
106+
'toolkit_getExternalResource',
107+
{ url: samAndCfnSchemaUrl, passive: true, result: 'Cancelled', reason: 'Cache hit' },
108+
3
109+
)
110+
})
111+
})

src/shared/resourcefetcher/httpResourceFetcher.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { getLogger, Logger } from '../logger'
1515
import { ResourceFetcher } from './resourcefetcher'
1616
import { Timeout, CancellationError, CancelEvent } from '../utilities/timeoutUtils'
1717
import { isCloud9 } from '../extensionUtilities'
18+
import { Headers } from 'got/dist/source/core'
1819

1920
// XXX: patched Got module for compatability with older VS Code versions (e.g. Cloud9)
2021
// `got` has also deprecated `urlToOptions`
@@ -35,6 +36,8 @@ type FetcherResult = Promise<void> & {
3536
fsStream: fs.WriteStream
3637
}
3738

39+
type RequestHeaders = { eTag?: string; gZip?: boolean }
40+
3841
export class HttpResourceFetcher implements ResourceFetcher {
3942
private readonly logger: Logger = getLogger()
4043

@@ -79,6 +82,40 @@ export class HttpResourceFetcher implements ResourceFetcher {
7982
return this.downloadRequest()
8083
}
8184

85+
/**
86+
* Requests for new content but additionally uses the given E-Tag.
87+
* If no E-Tag is given it behaves as a normal request.
88+
*
89+
* @param eTag
90+
* @returns object with optional content. If content is undefined it implies the provided
91+
* E-Tag matched the server's, so no content was in response. E-Tag is the E-Tag
92+
* of the latest content
93+
*/
94+
public async getNewETagContent(eTag?: string): Promise<{ content?: string; eTag: string }> {
95+
const response = await this.getResponseFromGetRequest(this.params.timeout, { eTag, gZip: true })
96+
97+
const eTagResponse = response.headers.etag
98+
if (!eTagResponse) {
99+
throw new Error(`This URL does not support E-Tags. Cannot use this function for: ${this.url.toString()}`)
100+
}
101+
102+
// NOTE: Even with use of `gzip` encoding header, the response content is uncompressed.
103+
// Most likely due to the http request library uncompressing it for us.
104+
let contents: string | undefined = response.body.toString()
105+
if (response.statusCode === 304) {
106+
// Explanation: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
107+
contents = undefined
108+
this.logger.verbose(`E-Tag, ${eTagResponse}, matched. No content downloaded from: ${this.url}`)
109+
} else {
110+
this.logger.verbose(`No E-Tag match. Downloaded content from: ${this.logText()}`)
111+
if (this.params.onSuccess) {
112+
this.params.onSuccess(contents)
113+
}
114+
}
115+
116+
return { content: contents, eTag: eTagResponse }
117+
}
118+
82119
private async downloadRequest(): Promise<string | undefined> {
83120
try {
84121
// HACK(?): receiving JSON as a string without `toString` makes it so we can't deserialize later
@@ -111,7 +148,7 @@ export class HttpResourceFetcher implements ResourceFetcher {
111148
// TODO: make pipeLocation a vscode.Uri
112149
private pipeGetRequest(pipeLocation: string, timeout?: Timeout): FetcherResult {
113150
const requester = isCloud9() ? patchedGot : got
114-
const requestStream = requester.stream(this.url, { headers: { 'User-Agent': VSCODE_EXTENSION_ID.awstoolkit } })
151+
const requestStream = requester.stream(this.url, { headers: this.buildRequestHeaders() })
115152
const fsStream = fs.createWriteStream(pipeLocation)
116153

117154
const done = new Promise<void>((resolve, reject) => {
@@ -133,10 +170,10 @@ export class HttpResourceFetcher implements ResourceFetcher {
133170
return Object.assign(done, { requestStream, fsStream })
134171
}
135172

136-
private async getResponseFromGetRequest(timeout?: Timeout): Promise<Response<string>> {
173+
private async getResponseFromGetRequest(timeout?: Timeout, headers?: RequestHeaders): Promise<Response<string>> {
137174
const requester = isCloud9() ? patchedGot : got
138175
const promise = requester(this.url, {
139-
headers: { 'User-Agent': VSCODE_EXTENSION_ID.awstoolkit },
176+
headers: this.buildRequestHeaders(headers),
140177
})
141178

142179
const cancelListener = timeout?.token.onCancellationRequested(event => {
@@ -148,6 +185,22 @@ export class HttpResourceFetcher implements ResourceFetcher {
148185

149186
return promise
150187
}
188+
189+
private buildRequestHeaders(requestHeaders?: RequestHeaders): Headers {
190+
const headers: Headers = {}
191+
192+
headers['User-Agent'] = VSCODE_EXTENSION_ID.awstoolkit
193+
194+
if (requestHeaders?.eTag !== undefined) {
195+
headers['If-None-Match'] = requestHeaders.eTag
196+
}
197+
198+
if (requestHeaders?.gZip) {
199+
headers['Accept-Encoding'] = 'gzip'
200+
}
201+
202+
return headers
203+
}
151204
}
152205

153206
/**

src/shared/schemas.ts

Lines changed: 109 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import { AWS_SCHEME } from './constants'
1717
import { writeFile } from 'fs-extra'
1818
import { SystemUtilities } from './systemUtilities'
1919
import { normalizeVSCodeUri } from './utilities/vsCodeUtils'
20+
import { telemetry } from './telemetry/telemetry'
2021

21-
const goformationManifestUrl = 'https://api.github.com/repos/awslabs/goformation/releases/latest'
22+
// Note: this file is currently 12+ MB. When requesting it, specify compression/gzip.
23+
export const samAndCfnSchemaUrl = 'https://raw.githubusercontent.com/aws/serverless-application-model/main/samtranslator/schema/schema.json'
2224
const devfileManifestUrl = 'https://api.github.com/repos/devfile/api/releases/latest'
2325
const schemaPrefix = `${AWS_SCHEME}://`
2426

@@ -140,41 +142,41 @@ export class SchemaService {
140142
* @param extensionContext VSCode extension context
141143
*/
142144
export async function getDefaultSchemas(extensionContext: vscode.ExtensionContext): Promise<Schemas | undefined> {
143-
const cfnSchemaUri = vscode.Uri.joinPath(extensionContext.globalStorageUri, 'cloudformation.schema.json')
144-
const samSchemaUri = vscode.Uri.joinPath(extensionContext.globalStorageUri, 'sam.schema.json')
145145
const devfileSchemaUri = vscode.Uri.joinPath(extensionContext.globalStorageUri, 'devfile.schema.json')
146-
147-
const goformationSchemaVersion = await getPropertyFromJsonUrl(goformationManifestUrl, 'tag_name')
148146
const devfileSchemaVersion = await getPropertyFromJsonUrl(devfileManifestUrl, 'tag_name')
149147

148+
// Sam schema is a superset of Cfn schema, so we can use it for both
149+
const samAndCfnSchemaDestinationUri = vscode.Uri.joinPath(extensionContext.globalStorageUri, 'sam.schema.json')
150+
const samAndCfnCacheKey = 'samAndCfnSchemaVersion'
151+
150152
const schemas: Schemas = {}
151153

152154
try {
153-
await updateSchemaFromRemote({
154-
destination: cfnSchemaUri,
155-
version: goformationSchemaVersion,
156-
url: `https://raw.githubusercontent.com/awslabs/goformation/${goformationSchemaVersion}/schema/cloudformation.schema.json`,
157-
cacheKey: 'cfnSchemaVersion',
155+
await updateSchemaFromRemoteETag({
156+
destination: samAndCfnSchemaDestinationUri,
157+
eTag: undefined,
158+
url: samAndCfnSchemaUrl,
159+
cacheKey: samAndCfnCacheKey,
158160
extensionContext,
159161
title: schemaPrefix + 'cloudformation.schema.json',
160162
})
161-
schemas['cfn'] = cfnSchemaUri
163+
schemas['cfn'] = samAndCfnSchemaDestinationUri
162164
} catch (e) {
163-
getLogger().verbose('Could not download cfn schema: %s', (e as Error).message)
165+
getLogger().verbose('Could not download sam/cfn schema: %s', (e as Error).message)
164166
}
165167

166168
try {
167-
await updateSchemaFromRemote({
168-
destination: samSchemaUri,
169-
version: goformationSchemaVersion,
170-
url: `https://raw.githubusercontent.com/awslabs/goformation/${goformationSchemaVersion}/schema/sam.schema.json`,
171-
cacheKey: 'samSchemaVersion',
169+
await updateSchemaFromRemoteETag({
170+
destination: samAndCfnSchemaDestinationUri,
171+
eTag: undefined,
172+
url: samAndCfnSchemaUrl,
173+
cacheKey: samAndCfnCacheKey,
172174
extensionContext,
173175
title: schemaPrefix + 'sam.schema.json',
174176
})
175-
schemas['sam'] = samSchemaUri
177+
schemas['sam'] = samAndCfnSchemaDestinationUri
176178
} catch (e) {
177-
getLogger().verbose('Could not download sam schema: %s', (e as Error).message)
179+
getLogger().verbose('Could not download sam/cfn schema: %s', (e as Error).message)
178180
}
179181

180182
try {
@@ -231,13 +233,7 @@ export async function updateSchemaFromRemote(params: {
231233
throw new Error(`failed to resolve schema: ${params.destination}`)
232234
}
233235

234-
const parsedFile = { ...JSON.parse(content), title: params.title }
235-
const dir = vscode.Uri.joinPath(params.destination, '..')
236-
await SystemUtilities.createDirectory(dir)
237-
await writeFile(params.destination.fsPath, JSON.stringify(parsedFile))
238-
await params.extensionContext.globalState.update(params.cacheKey, params.version).then(undefined, err => {
239-
getLogger().warn(`schemas: failed to update cache key for "${params.title}": ${err?.message}`)
240-
})
236+
await doCacheContent(content, params)
241237
} catch (err) {
242238
if (cachedContent) {
243239
getLogger().warn(
@@ -251,6 +247,93 @@ export async function updateSchemaFromRemote(params: {
251247
}
252248
}
253249

250+
/**
251+
* Fetches url content and caches locally. Uses E-Tag to determine if cached
252+
* content can be used.
253+
* @param params.destination Path to local file
254+
* @param params.eTag E-Tag to send with fetch request. If this matches the url's it means we can use our cache.
255+
* @param params.url Url to fetch from
256+
* @param params.cacheKey Cache key to check version against
257+
* @param params.extensionContext VSCode extension context
258+
*/
259+
export async function updateSchemaFromRemoteETag(params: {
260+
destination: vscode.Uri
261+
eTag?: string
262+
url: string
263+
cacheKey: string
264+
extensionContext: vscode.ExtensionContext
265+
title: string
266+
}): Promise<void> {
267+
const cachedETag = params.extensionContext.globalState.get<string>(params.cacheKey)
268+
269+
// Check that the cached file actually can be fetched. Else we might
270+
// never update the cache.
271+
const fileFetcher = new FileResourceFetcher(params.destination.fsPath)
272+
const cachedContent = await fileFetcher.get()
273+
274+
// Determine if existing cached content is sufficient
275+
const needsUpdate = cachedETag === undefined || cachedETag !== params.eTag
276+
const hasCachedContent = cachedContent !== undefined
277+
if (hasCachedContent && !needsUpdate) {
278+
return
279+
}
280+
281+
try {
282+
// Only use our cached E-Tag if we have it + cached content
283+
const eTagToRequest = !!cachedETag && cachedContent !== undefined ? cachedETag : params.eTag
284+
285+
const httpFetcher = new HttpResourceFetcher(params.url, { showUrl: true })
286+
const response = await httpFetcher.getNewETagContent(eTagToRequest)
287+
const { content, eTag: latestETag } = response
288+
if (content === undefined) {
289+
// Our cached content is the latest
290+
telemetry.toolkit_getExternalResource.emit({
291+
url: params.url,
292+
passive: true,
293+
result: 'Cancelled',
294+
reason: 'Cache hit',
295+
})
296+
return
297+
}
298+
telemetry.toolkit_getExternalResource.emit({ url: params.url, passive: true, result: 'Succeeded' })
299+
await doCacheContent(content, { ...params, version: latestETag })
300+
} catch (err) {
301+
if (cachedContent) {
302+
getLogger().warn(
303+
`schemas: Using cached schema instead since failed to fetch the latest version for "${params.title}": %s`,
304+
err
305+
)
306+
} else {
307+
throw err
308+
}
309+
}
310+
}
311+
312+
/**
313+
* Cache content to our extension context
314+
* @param content
315+
* @param params.version An identifier for a version of the resource. Can include an E-Tag value
316+
*/
317+
async function doCacheContent(
318+
content: string,
319+
params: {
320+
destination: vscode.Uri
321+
version?: string
322+
url: string
323+
cacheKey: string
324+
extensionContext: vscode.ExtensionContext
325+
title: string
326+
}
327+
): Promise<void> {
328+
const parsedFile = { ...JSON.parse(content), title: params.title }
329+
const dir = vscode.Uri.joinPath(params.destination, '..')
330+
await SystemUtilities.createDirectory(dir)
331+
await writeFile(params.destination.fsPath, JSON.stringify(parsedFile))
332+
await params.extensionContext.globalState.update(params.cacheKey, params.version).then(undefined, err => {
333+
getLogger().warn(`schemas: failed to update cache key for "${params.title}": ${err?.message}`)
334+
})
335+
}
336+
254337
/**
255338
* Adds custom tags to the YAML extension's settings in order to hide error
256339
* notifications for SAM/CFN intrinsic functions if a user has the YAML extension.

src/test/testUtil.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,18 @@ export function getMetrics<K extends MetricName>(
145145
return globals.telemetry.logger.query(query) as unknown as Partial<MetricShapes[K]>[]
146146
}
147147

148-
/*
149-
* Finds the first emitted telemetry metric with the given name, then checks if the metadata fields
150-
* match the expected values.
148+
/**
149+
* Finds the emitted telemetry metric with the given name, then checks if the metadata fields
150+
* match the expected values. Defaults to checking against the first occurrence, but this
151+
* can be overidden.
152+
*
153+
* @param eventNum The nth occurrence of the event you want to test against, defaults to the first.
151154
*/
152-
export function assertTelemetry<K extends MetricName>(name: K, expected: MetricShapes[K]): void | never {
155+
export function assertTelemetry<K extends MetricName>(
156+
name: K,
157+
expected: MetricShapes[K],
158+
eventNum: number = 0
159+
): void | never {
153160
const expectedCopy = { ...expected } as { -readonly [P in keyof MetricShapes[K]]: MetricShapes[K][P] }
154161
const passive = expectedCopy?.passive
155162
const query = { metricName: name, excludeKeys: ['awsAccount', 'duration'] }
@@ -164,7 +171,7 @@ export function assertTelemetry<K extends MetricName>(name: K, expected: MetricS
164171

165172
const metadata = globals.telemetry.logger.query(query)
166173
assert.ok(metadata.length > 0, `Telemetry did not contain any metrics with the name "${name}"`)
167-
assert.deepStrictEqual(metadata[0], expectedCopy)
174+
assert.deepStrictEqual(metadata[eventNum], expectedCopy)
168175

169176
if (passive !== undefined) {
170177
const metric = globals.telemetry.logger.queryFull(query)

0 commit comments

Comments
 (0)