UNPKG

25.1 kBPlain TextView Raw
1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT License. See License.txt in the project root for license information.
3
4import { delay, HttpMethods, HttpOperationResponse, RequestOptionsBase, RestError, stripRequest, WebResource, OperationResponse, OperationSpec } from "@azure/ms-rest-js";
5import { AzureServiceClient } from "./azureServiceClient";
6import { LongRunningOperationStates } from "./util/constants";
7
8export type LROPollStrategyType = "AzureAsyncOperation" | "Location" | "GetResource";
9
10export interface LROPollState {
11 pollStrategyType: LROPollStrategyType;
12 initialResponse: HttpOperationResponse;
13 state: LongRunningOperationStates;
14 mostRecentRequest: WebResource;
15 mostRecentResponse: HttpOperationResponse;
16 resource: any;
17 azureAsyncOperationHeaderValue?: string;
18 locationHeaderValue?: string;
19 options?: RequestOptionsBase;
20}
21
22/**
23 * A long-running operation polling strategy base class that other polling strategies should extend.
24 */
25export abstract class LROPollStrategy {
26 constructor(private readonly _azureServiceClient: AzureServiceClient, protected readonly _pollState: LROPollState) {
27 }
28
29 public getOperationStatus(): LongRunningOperationStates {
30 return this._pollState.state;
31 }
32
33 /**
34 * Get whether or not this poll strategy's LRO is finished.
35 * @returns Whether or not this poll strategy's LRO is finished.
36 */
37 public isFinished(): boolean {
38 return isFinished(this._pollState.state);
39 }
40
41 /**
42 * Send poll requests that check the LRO's status until it is determined that the LRO is finished.
43 * @returns Whether or not the LRO succeeded.
44 */
45 public async pollUntilFinished(): Promise<boolean> {
46 while (!this.isFinished()) {
47 const delayInSeconds: number = getDelayInSeconds(this._azureServiceClient, this._pollState.mostRecentResponse);
48 await delay(delayInSeconds * 1000);
49
50 await this.sendPollRequest();
51 }
52 return this.isFinalStatusAcceptable();
53 }
54
55 /**
56 * Send a single poll request that checks the LRO's status and return the response. If the LRO is
57 * finished, then no request will be sent and the last response received will be returned.
58 */
59 public abstract sendPollRequest(): Promise<void>;
60
61 public abstract isFinalStatusAcceptable(): boolean;
62
63 protected shouldDoFinalGetResourceRequest(): boolean {
64 const initialRequestMethod: HttpMethods = this._pollState.initialResponse.request.method;
65 return !this._pollState.resource && (initialRequestMethod === "PUT" || initialRequestMethod === "PATCH" || initialRequestMethod === "POST");
66 }
67
68 protected abstract doFinalGetResourceRequest(): Promise<void>;
69
70 public getMostRecentResponse(): HttpOperationResponse {
71 return this._pollState.mostRecentResponse;
72 }
73
74 public async getOperationResponse(): Promise<HttpOperationResponse> {
75 if (this.shouldDoFinalGetResourceRequest()) {
76 await this.doFinalGetResourceRequest();
77 }
78 const response: HttpOperationResponse = this._pollState.mostRecentResponse;
79 const result: HttpOperationResponse = {
80 ...response,
81 headers: response.headers.clone()
82 };
83 const resource: any = this._pollState.resource;
84 if (!resource) {
85 result.bodyAsText = response.bodyAsText;
86 result.parsedBody = response.parsedBody;
87 } else if (typeof resource.valueOf() === "string") {
88 result.bodyAsText = resource;
89 try {
90 result.parsedBody = JSON.parse(resource);
91 } catch (err) {
92 // There was an error parsing the JSON. Hence we set the resource as-is. Most likely, the
93 // resource is a string that was already parsed.
94 result.parsedBody = resource;
95 }
96 } else {
97 result.bodyAsText = JSON.stringify(resource);
98 result.parsedBody = resource;
99 }
100 return result;
101 }
102
103 public getRestError(): RestError {
104 const error = new RestError("");
105 error.request = stripRequest(this._pollState.mostRecentRequest);
106 error.response = this._pollState.mostRecentResponse;
107 error.message = `Long running operation failed with status: "${this._pollState.state}".`;
108 error.body = this._pollState.resource;
109 if (error.body) {
110 const innerError: any = error.body.error;
111 if (innerError) {
112 if (innerError.message) {
113 error.message = `Long running operation failed with error: "${innerError.message}".`;
114 }
115 if (innerError.code) {
116 error.code = innerError.code;
117 }
118 }
119 }
120 return error;
121 }
122
123 protected updateState(url: string, shouldDeserialize: boolean | ((response: HttpOperationResponse) => boolean)): Promise<void> {
124 return this.updateOperationStatus(url, shouldDeserialize).then(result => {
125 this._pollState.state = getProvisioningState(result.parsedBody) || "Succeeded";
126 this._pollState.mostRecentResponse = result;
127 this._pollState.mostRecentRequest = result.request;
128 this._pollState.resource = getResponseBody(result);
129 }).catch((error) => {
130 let resultStatus: number | undefined;
131 if (error.response && error.response.status) {
132 resultStatus = error.response.status;
133 if (this._pollState.initialResponse.request.method !== "DELETE" || resultStatus! < 400 || 499 < resultStatus!) {
134 throw error;
135 }
136 } else {
137 throw error;
138 }
139 });
140 }
141
142 /**
143 * Retrieves operation status by querying the operation URL.
144 * @param {string} statusUrl URL used to poll operation result.
145 */
146 protected updateOperationStatus(statusUrl: string, shouldDeserialize: boolean | ((response: HttpOperationResponse) => boolean)): Promise<HttpOperationResponse> {
147 const requestUrl: string = statusUrl.replace(" ", "%20");
148 const httpRequest = new WebResource(requestUrl, "GET");
149 const pollState: LROPollState = this._pollState;
150 httpRequest.operationSpec = pollState.mostRecentRequest.operationSpec;
151 httpRequest.shouldDeserialize = shouldDeserialize;
152 httpRequest.operationResponseGetter = getOperationResponse;
153 const options: RequestOptionsBase | undefined = pollState.options;
154 if (options && options.customHeaders) {
155 const customHeaders = options.customHeaders;
156 for (const headerName of Object.keys(customHeaders)) {
157 httpRequest.headers.set(headerName, customHeaders[headerName]);
158 }
159 }
160 return this._azureServiceClient.sendRequest(httpRequest);
161 }
162
163 public getPollState(): LROPollState {
164 return this._pollState;
165 }
166}
167
168function getOperationResponse(operationSpec: OperationSpec, response: HttpOperationResponse): OperationResponse | undefined {
169 const statusCode: number = response.status;
170 const operationResponses: { [statusCode: string]: OperationResponse } = operationSpec.responses;
171 let result: OperationResponse | undefined = operationResponses[statusCode];
172 if (!result) {
173 if (statusCode === 200) {
174 result = operationResponses[201] || operationResponses[202];
175 } else if (201 <= statusCode && statusCode <= 299) {
176 result = {};
177 }
178 }
179 return result;
180}
181
182export function getDelayInSeconds(azureServiceClient: AzureServiceClient, previousResponse: HttpOperationResponse): number {
183 let delayInSeconds = 30;
184 if (azureServiceClient.longRunningOperationRetryTimeout != undefined) {
185 delayInSeconds = azureServiceClient.longRunningOperationRetryTimeout;
186 } else {
187 const retryAfterHeaderValue: string | undefined = previousResponse.headers.get("retry-after");
188 if (retryAfterHeaderValue) {
189 const retryAfterDelayInSeconds: number = parseInt(retryAfterHeaderValue);
190 if (!Number.isNaN(retryAfterDelayInSeconds)) {
191 delayInSeconds = retryAfterDelayInSeconds;
192 }
193 }
194 }
195 return delayInSeconds;
196}
197
198function getProvisioningState(responseBody: any): LongRunningOperationStates | undefined {
199 let result: LongRunningOperationStates | undefined;
200 if (responseBody) {
201 if (responseBody.provisioningState) {
202 result = responseBody.provisioningState;
203 } else if (responseBody.properties) {
204 result = responseBody.properties.provisioningState;
205 }
206 }
207 return result;
208}
209
210function getResponseBody(response: HttpOperationResponse): any {
211 let result: any;
212 try {
213 if (response.parsedBody) {
214 result = response.parsedBody;
215 } else if (response.bodyAsText && response.bodyAsText.length > 0) {
216 result = JSON.parse(response.bodyAsText);
217 }
218 } catch (error) {
219 const deserializationError = new RestError(`Error "${error}" occurred in parsing the responseBody " +
220 "while creating the PollingState for Long Running Operation- "${response.bodyAsText}"`);
221 deserializationError.request = response.request;
222 deserializationError.response = response;
223 throw deserializationError;
224 }
225 return result;
226}
227
228function getStatusFromResponse(response: HttpOperationResponse, responseBody?: any): LongRunningOperationStates {
229 if (responseBody == undefined) {
230 responseBody = getResponseBody(response);
231 }
232
233 let result: LongRunningOperationStates;
234 switch (response.status) {
235 case 202:
236 result = "InProgress";
237 break;
238
239 case 204:
240 result = "Succeeded";
241 break;
242
243 case 201:
244 result = getProvisioningState(responseBody) || "InProgress";
245 break;
246
247 case 200:
248 const provisioningState: LongRunningOperationStates | undefined = getProvisioningState(responseBody);
249 if (provisioningState) {
250 result = provisioningState;
251 } else if (getAzureAsyncOperationHeaderValue(response) || getLocationHeaderValue(response)) {
252 result = "InProgress";
253 } else {
254 result = "Succeeded";
255 }
256 break;
257
258 default:
259 result = "Failed";
260 break;
261 }
262 return result;
263}
264
265const terminalStates: LongRunningOperationStates[] = ["Succeeded", "Failed", "Canceled", "Cancelled"];
266
267/**
268 * Get whether or not a long-running operation with the provided status is finished.
269 * @param status The current status of a long-running operation.
270 * @returns Whether or not a long-running operation with the provided status is finished.
271 */
272export function isFinished(status: LongRunningOperationStates): boolean {
273 let result = false;
274 for (const terminalState of terminalStates) {
275 if (longRunningOperationStatesEqual(status, terminalState)) {
276 result = true;
277 break;
278 }
279 }
280 return result;
281}
282
283export function longRunningOperationStatesEqual(lhs: LongRunningOperationStates, rhs: LongRunningOperationStates): boolean {
284 const lhsLowerCased: string = lhs && lhs.toLowerCase();
285 const rhsLowerCased: string = rhs && rhs.toLowerCase();
286 return lhsLowerCased === rhsLowerCased;
287}
288
289/**
290 * Create a new long-running operation polling strategy based on the provided initial response.
291 * @param initialResponse The initial response to the long-running operation's initial request.
292 * @param azureServiceClient The AzureServiceClient that was used to send the initial request.
293 * @param options Any options that were provided to the initial request.
294 */
295export function createLROPollStrategyFromInitialResponse(initialResponse: HttpOperationResponse, azureServiceClient: AzureServiceClient, options?: RequestOptionsBase): LROPollStrategy | undefined {
296 const initialRequestMethod: HttpMethods = initialResponse.request.method;
297 const initialResponseStatus: number = initialResponse.status;
298
299 let lroPollStrategyType: LROPollStrategyType | undefined;
300 if (getAzureAsyncOperationHeaderValue(initialResponse)) {
301 lroPollStrategyType = "AzureAsyncOperation";
302 } else if (getLocationHeaderValue(initialResponse)) {
303 lroPollStrategyType = "Location";
304 } else if (initialRequestMethod === "PUT" || initialRequestMethod === "PATCH") {
305 lroPollStrategyType = "GetResource";
306 } else if (initialResponseStatus !== 201 && initialResponseStatus !== 202 && !isFinished(getStatusFromResponse(initialResponse))) {
307 throw new Error("Can't determine long running operation polling strategy.");
308 }
309
310 let result: LROPollStrategy | undefined;
311 if (lroPollStrategyType) {
312 const resource: any = getResponseBody(initialResponse);
313 const lroPollState: LROPollState = {
314 pollStrategyType: lroPollStrategyType,
315 options: options,
316 initialResponse: initialResponse,
317 mostRecentResponse: initialResponse,
318 mostRecentRequest: initialResponse.request,
319 azureAsyncOperationHeaderValue: getAzureAsyncOperationHeaderValue(initialResponse),
320 locationHeaderValue: getLocationHeaderValue(initialResponse),
321 resource: resource,
322 state: getStatusFromResponse(initialResponse, resource)
323 };
324 result = createLROPollStrategyFromPollState(azureServiceClient, lroPollState);
325 } else {
326 result = undefined;
327 }
328 return result;
329}
330
331export function createLROPollStrategyFromPollState(azureServiceClient: AzureServiceClient, lroPollState: LROPollState): LROPollStrategy | undefined {
332 let result: LROPollStrategy;
333 switch (lroPollState.pollStrategyType) {
334 case "AzureAsyncOperation":
335 result = new AzureAsyncOperationLROPollStrategy(azureServiceClient, lroPollState);
336 break;
337
338 case "Location":
339 result = new LocationLROPollStrategy(azureServiceClient, lroPollState);
340 break;
341
342 case "GetResource":
343 result = new GetResourceLROPollStrategy(azureServiceClient, lroPollState);
344 break;
345
346 default:
347 throw new Error(`Unrecognized LRO poll strategy type: "${lroPollState.pollStrategyType}"`);
348 break;
349 }
350 return result;
351}
352
353function getLocationHeaderValue(response: HttpOperationResponse): string | undefined {
354 return response.headers.get("location");
355}
356
357/**
358 * A long-running operation polling strategy that is based on the location header.
359 */
360class LocationLROPollStrategy extends LROPollStrategy {
361 private locationStrategyShouldDeserialize(parsedResponse: HttpOperationResponse): boolean {
362 let shouldDeserialize = false;
363
364 const initialResponse: HttpOperationResponse = this._pollState.initialResponse;
365 const initialRequestMethod: HttpMethods = initialResponse.request.method;
366 const statusCode: number = parsedResponse.status;
367 if (statusCode === 200 ||
368 (statusCode === 201 && (initialRequestMethod === "PUT" || initialRequestMethod === "PATCH")) ||
369 (statusCode === 204 && (initialRequestMethod === "DELETE" || initialRequestMethod === "POST"))) {
370 shouldDeserialize = true;
371 }
372
373 return shouldDeserialize;
374 }
375 /**
376 * Retrieve PUT operation status by polling from "location" header.
377 * @param {string} method - The HTTP method.
378 * @param {PollingState} pollingState - The object to persist current operation state.
379 */
380 public sendPollRequest(): Promise<void> {
381 const lroPollState: LROPollState = this._pollState;
382 return this.updateOperationStatus(lroPollState.locationHeaderValue!, this.locationStrategyShouldDeserialize.bind(this)).then((result: HttpOperationResponse) => {
383 const locationHeaderValue: string | undefined = getLocationHeaderValue(result);
384 if (locationHeaderValue) {
385 lroPollState.locationHeaderValue = locationHeaderValue;
386 }
387
388 lroPollState.mostRecentResponse = result;
389 lroPollState.mostRecentRequest = result.request;
390
391 const initialResponse: HttpOperationResponse = lroPollState.initialResponse;
392 const initialRequestMethod: HttpMethods = initialResponse.request.method;
393 const initialResponseStatusCode: number = initialResponse.status;
394 const statusCode: number = result.status;
395 if (statusCode === 202) {
396 lroPollState.state = "InProgress";
397 } else if (statusCode === 200 ||
398 (statusCode === 201 && (initialRequestMethod === "PUT" || initialRequestMethod === "PATCH")) ||
399 (statusCode === 204 && (initialRequestMethod === "DELETE" || initialRequestMethod === "POST"))) {
400 lroPollState.state = "Succeeded";
401 lroPollState.resource = getResponseBody(result);
402 } else if (statusCode === 404 && initialRequestMethod === "POST" &&
403 (initialResponseStatusCode === 200 || initialResponseStatusCode === 201 || initialResponseStatusCode === 202)) {
404 lroPollState.state = "Failed";
405 lroPollState.resource = getResponseBody(result);
406 } else if (400 <= statusCode && statusCode <= 499) {
407 const resultBody: string = result.bodyAsText!;
408 let errorMessage: string = resultBody;
409 try {
410 const resultObject = JSON.parse(resultBody);
411 errorMessage = resultObject.message;
412 } catch (parseError) {
413 // Ignore the exception, use resultBody as the error message
414 }
415
416 throw new RestError(errorMessage, undefined, statusCode, stripRequest(result.request), result, resultBody);
417 } else {
418 throw new Error(`The response with status code ${statusCode} from polling for long running operation url "${lroPollState.locationHeaderValue}" is not valid.`);
419 }
420 });
421 }
422
423 public isFinalStatusAcceptable(): boolean {
424 const lroPollState: LROPollState = this._pollState;
425 const initialResponse: HttpOperationResponse = lroPollState.initialResponse;
426 const initialResponseStatusCode: number = initialResponse.status;
427 return longRunningOperationStatesEqual(lroPollState.state, "Succeeded") ||
428 (initialResponse.request.method === "POST" && lroPollState.mostRecentResponse.status === 404 &&
429 (initialResponseStatusCode === 200 ||
430 initialResponseStatusCode === 201 ||
431 initialResponseStatusCode === 202));
432 }
433
434 protected shouldDoFinalGetResourceRequest(): boolean {
435 const lroPollState: LROPollState = this._pollState;
436 const initialResponse: HttpOperationResponse = lroPollState.initialResponse;
437 let result: boolean;
438 const initialRequestMethod: HttpMethods = initialResponse.request.method;
439 const initialResponseStatusCode: number = initialResponse.status;
440 if (initialRequestMethod === "POST" && lroPollState.mostRecentResponse.status === 404 &&
441 (initialResponseStatusCode === 200 ||
442 initialResponseStatusCode === 201 ||
443 initialResponseStatusCode === 202)) {
444 result = false;
445 } else {
446 result = super.shouldDoFinalGetResourceRequest() ||
447 (initialRequestMethod === "POST" && initialResponseStatusCode === 201);
448 }
449 return result;
450 }
451
452 protected doFinalGetResourceRequest(): Promise<void> {
453 const lroPollState: LROPollState = this._pollState;
454 const initialResponse: HttpOperationResponse = lroPollState.initialResponse;
455 let getResourceRequestUrl: string;
456 const initialResponseStatusCode: number = initialResponse.status;
457 const initialRequest: WebResource = initialResponse.request;
458 if (initialRequest.method === "POST" &&
459 (initialResponseStatusCode === 200 ||
460 initialResponseStatusCode === 201 ||
461 initialResponseStatusCode === 202)) {
462 getResourceRequestUrl = lroPollState.locationHeaderValue!;
463 } else {
464 getResourceRequestUrl = initialRequest.url;
465 }
466 return this.updateState(getResourceRequestUrl, true);
467 }
468}
469
470function getAzureAsyncOperationHeaderValue(response: HttpOperationResponse): string | undefined {
471 return response.headers.get("azure-asyncoperation");
472}
473
474/**
475 * A long-running operation polling strategy that is based on the azure-asyncoperation header.
476 */
477class AzureAsyncOperationLROPollStrategy extends LROPollStrategy {
478 /**
479 * Retrieve operation status by polling from "azure-asyncoperation" header.
480 * @param {PollingState} pollingState - The object to persist current operation state.
481 * @param {boolean} inPostOrDelete - Invoked by Post Or Delete operation.
482 */
483 public sendPollRequest(): Promise<void> {
484 const lroPollState: LROPollState = this._pollState;
485 return this.updateOperationStatus(lroPollState.azureAsyncOperationHeaderValue!, false).then((response: HttpOperationResponse) => {
486 const statusCode: number = response.status;
487 const parsedResponse: any = response.parsedBody;
488 if (statusCode !== 200 && statusCode !== 201 && statusCode !== 202 && statusCode !== 204) {
489 const error = new RestError(`Invalid status code (${statusCode}) with response body "${response.bodyAsText}" occurred when polling for operation status.`);
490 error.statusCode = statusCode;
491 error.request = stripRequest(response.request);
492 error.response = response;
493 error.body = parsedResponse;
494 throw error;
495 }
496
497 if (!parsedResponse) {
498 throw new Error("The response from long running operation does not contain a body.");
499 } else if (!parsedResponse.status) {
500 throw new Error(`The response "${response.bodyAsText}" from long running operation does not contain the status property.`);
501 }
502
503 const azureAsyncOperationHeaderValue: string | undefined = getAzureAsyncOperationHeaderValue(response);
504 if (azureAsyncOperationHeaderValue) {
505 lroPollState.azureAsyncOperationHeaderValue = azureAsyncOperationHeaderValue;
506 }
507
508 lroPollState.state = parsedResponse.status;
509 lroPollState.mostRecentResponse = response;
510 lroPollState.mostRecentRequest = response.request;
511 lroPollState.resource = getResponseBody(response);
512 });
513 }
514
515 protected shouldDoFinalGetResourceRequest(): boolean {
516 const lroPollState: LROPollState = this._pollState;
517 const initialResponse: HttpOperationResponse = lroPollState.initialResponse;
518 const initialRequestMethod: HttpMethods = initialResponse.request.method;
519 let result = false;
520 if (initialRequestMethod === "PUT" || initialRequestMethod === "PATCH") {
521 result = true;
522 } else {
523 if (lroPollState.locationHeaderValue) {
524 const initialResponseStatusCode: number = initialResponse.status;
525 if (initialRequestMethod === "POST") {
526 result = initialResponseStatusCode === 200 || initialResponseStatusCode === 201;
527 } else if (initialRequestMethod === "DELETE") {
528 result = initialResponseStatusCode === 200 || initialResponseStatusCode === 202;
529 }
530 }
531 }
532 return result;
533 }
534
535 protected doFinalGetResourceRequest(): Promise<void> {
536 const lroPollState: LROPollState = this._pollState;
537 const locationHeaderValue: string | undefined = lroPollState.locationHeaderValue;
538 const initialResponse: HttpOperationResponse = lroPollState.initialResponse;
539 const initialRequest: WebResource = initialResponse.request;
540 let getResourceRequestUrl: string = initialRequest.url;
541 if (locationHeaderValue) {
542 const initialRequestMethod: HttpMethods = initialRequest.method;
543 const initialResponseStatusCode: number = initialResponse.status;
544 if (initialRequestMethod === "POST" && (initialResponseStatusCode === 200 || initialResponseStatusCode === 201 || initialResponseStatusCode === 202)) {
545 getResourceRequestUrl = locationHeaderValue;
546 } else if (initialRequestMethod === "DELETE" && (initialResponseStatusCode === 200 || initialResponseStatusCode === 202)) {
547 getResourceRequestUrl = locationHeaderValue;
548 }
549 }
550 return this.updateState(getResourceRequestUrl, true);
551 }
552
553 public isFinalStatusAcceptable(): boolean {
554 const lroPollState: LROPollState = this._pollState;
555 const initialResponse: HttpOperationResponse = lroPollState.initialResponse;
556 const initialResponseStatusCode: number = initialResponse.status;
557 return longRunningOperationStatesEqual(lroPollState.state, "Succeeded") ||
558 (initialResponse.request.method === "POST" && (initialResponseStatusCode === 200 || initialResponseStatusCode === 201));
559 }
560}
561
562/**
563 * A long-running operation polling strategy that is based on the resource's provisioning state.
564 */
565class GetResourceLROPollStrategy extends LROPollStrategy {
566 public sendPollRequest(): Promise<void> {
567 const lroPollState: LROPollState = this._pollState;
568 return this.updateOperationStatus(lroPollState.initialResponse.request.url, false).then(result => {
569 const statusCode: number = result.status;
570 const responseBody: any = result.parsedBody;
571 if (statusCode !== 200 && statusCode !== 201 && statusCode !== 202 && statusCode !== 204) {
572 const error = new RestError(`Invalid status code with response body "${result.bodyAsText}" occurred when polling for operation status.`);
573 error.statusCode = statusCode;
574 error.request = stripRequest(result.request);
575 error.response = result;
576 error.body = responseBody;
577 throw error;
578 }
579
580 if (!result.parsedBody) {
581 throw new Error("The response from long running operation does not contain a body.");
582 }
583
584 lroPollState.state = getProvisioningState(result.parsedBody) || "Succeeded";
585 lroPollState.mostRecentResponse = result;
586 lroPollState.mostRecentRequest = result.request;
587 lroPollState.resource = getResponseBody(result);
588 });
589 }
590
591 public isFinalStatusAcceptable(): boolean {
592 return longRunningOperationStatesEqual(this._pollState.state, "Succeeded");
593 }
594
595 protected doFinalGetResourceRequest(): Promise<void> {
596 return this.sendPollRequest();
597 }
598}
599
\No newline at end of file