Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 27 additions & 21 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,12 @@ export class AppStoreServerAPIClient {
});
}

private encodePathSegment(value: string): string {
const encodedValue = encodeURIComponent(value)
// WHATWG URL parsing normalizes encoded dot-only segments, so encode them one more time.
return encodedValue === "." || encodedValue === ".." ? encodedValue.replace(/\./g, "%252E") : encodedValue
}

/**
* Uses a subscription’s product identifier to extend the renewal date for all of its eligible active subscribers.
*
Expand All @@ -321,7 +327,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/appstoreserverapi/extend_a_subscription_renewal_date Extend a Subscription Renewal Date}
*/
public async extendSubscriptionRenewalDate(originalTransactionId: string, extendRenewalDateRequest: ExtendRenewalDateRequest): Promise<ExtendRenewalDateResponse> {
return await this.makeRequest<ExtendRenewalDateResponse>("/inApps/v1/subscriptions/extend/" + originalTransactionId, "PUT", {}, extendRenewalDateRequest, new ExtendRenewalDateResponseValidator(), 'application/json');
return await this.makeRequest<ExtendRenewalDateResponse>("/inApps/v1/subscriptions/extend/" + this.encodePathSegment(originalTransactionId), "PUT", {}, extendRenewalDateRequest, new ExtendRenewalDateResponseValidator(), 'application/json');
}

/**
Expand All @@ -339,7 +345,7 @@ export class AppStoreServerAPIClient {
queryParameters["status"] = status.map(s => s.toString()) as [string];
}

return await this.makeRequest("/inApps/v1/subscriptions/" + anyTransactionId, "GET", queryParameters, null, new StatusResponseValidator(), undefined);
return await this.makeRequest("/inApps/v1/subscriptions/" + this.encodePathSegment(anyTransactionId), "GET", queryParameters, null, new StatusResponseValidator(), undefined);
}

/**
Expand All @@ -357,7 +363,7 @@ export class AppStoreServerAPIClient {
queryParameters["revision"] = [revision];
}

return await this.makeRequest("/inApps/v2/refund/lookup/" + anyTransactionId, "GET", queryParameters, null, new RefundHistoryResponseValidator(), undefined);
return await this.makeRequest("/inApps/v2/refund/lookup/" + this.encodePathSegment(anyTransactionId), "GET", queryParameters, null, new RefundHistoryResponseValidator(), undefined);
}

/**
Expand All @@ -370,7 +376,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/appstoreserverapi/get_status_of_subscription_renewal_date_extensions Get Status of Subscription Renewal Date Extensions}
*/
public async getStatusOfSubscriptionRenewalDateExtensions(requestIdentifier: string, productId: string): Promise<MassExtendRenewalDateStatusResponse> {
return await this.makeRequest("/inApps/v1/subscriptions/extend/mass/" + productId + "/" + requestIdentifier, "GET", {}, null, new MassExtendRenewalDateStatusResponseValidator(), undefined);
return await this.makeRequest("/inApps/v1/subscriptions/extend/mass/" + this.encodePathSegment(productId) + "/" + this.encodePathSegment(requestIdentifier), "GET", {}, null, new MassExtendRenewalDateStatusResponseValidator(), undefined);
}

/**
Expand All @@ -382,7 +388,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/appstoreserverapi/get_test_notification_status Get Test Notification Status}
*/
public async getTestNotificationStatus(testNotificationToken: string): Promise<CheckTestNotificationResponse> {
return await this.makeRequest("/inApps/v1/notifications/test/" + testNotificationToken, "GET", {}, null, new CheckTestNotificationResponseValidator(), undefined);
return await this.makeRequest("/inApps/v1/notifications/test/" + this.encodePathSegment(testNotificationToken), "GET", {}, null, new CheckTestNotificationResponseValidator(), undefined);
}

/**
Expand Down Expand Up @@ -441,7 +447,7 @@ export class AppStoreServerAPIClient {
if (transactionHistoryRequest.revoked !== undefined) {
queryParameters["revoked"] = [transactionHistoryRequest.revoked.toString()];
}
return await this.makeRequest("/inApps/" + version + "/history/" + anyTransactionId, "GET", queryParameters, null, new HistoryResponseValidator(), undefined);
return await this.makeRequest("/inApps/" + this.encodePathSegment(version) + "/history/" + this.encodePathSegment(anyTransactionId), "GET", queryParameters, null, new HistoryResponseValidator(), undefined);
}

/**
Expand All @@ -453,7 +459,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info Get Transaction Info}
*/
public async getTransactionInfo(transactionId: string): Promise<TransactionInfoResponse> {
return await this.makeRequest("/inApps/v1/transactions/" + transactionId, "GET", {}, null, new TransactionInfoResponseValidator(), undefined);
return await this.makeRequest("/inApps/v1/transactions/" + this.encodePathSegment(transactionId), "GET", {}, null, new TransactionInfoResponseValidator(), undefined);
}

/**
Expand All @@ -465,7 +471,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/appstoreserverapi/look_up_order_id Look Up Order ID}
*/
public async lookUpOrderId(orderId: string): Promise<OrderLookupResponse> {
return await this.makeRequest("/inApps/v1/lookup/" + orderId, "GET", {}, null, new OrderLookupResponseValidator(), undefined);
return await this.makeRequest("/inApps/v1/lookup/" + this.encodePathSegment(orderId), "GET", {}, null, new OrderLookupResponseValidator(), undefined);
}

/**
Expand All @@ -489,7 +495,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/appstoreserverapi/send-consumption-information-v1 Send Consumption Information}
*/
public async sendConsumptionData(transactionId: string, consumptionRequest: ConsumptionRequestV1): Promise<void> {
await this.makeRequest("/inApps/v1/transactions/consumption/" + transactionId, "PUT", {}, consumptionRequest, null, 'application/json');
await this.makeRequest("/inApps/v1/transactions/consumption/" + this.encodePathSegment(transactionId), "PUT", {}, consumptionRequest, null, 'application/json');
}

/**
Expand All @@ -501,7 +507,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/appstoreserverapi/send-consumption-information Send Consumption Information}
*/
public async sendConsumptionInformation(transactionId: string, consumptionRequest: ConsumptionRequest): Promise<void> {
await this.makeRequest("/inApps/v2/transactions/consumption/" + transactionId, "PUT", {}, consumptionRequest, null, 'application/json');
await this.makeRequest("/inApps/v2/transactions/consumption/" + this.encodePathSegment(transactionId), "PUT", {}, consumptionRequest, null, 'application/json');
}

/**
Expand All @@ -513,7 +519,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/appstoreserverapi/set-app-account-token Set App Account Token}
*/
public async setAppAccountToken(originalTransactionId: string, updateAppAccountTokenRequest: UpdateAppAccountTokenRequest): Promise<void> {
await this.makeRequest("/inApps/v1/transactions/" + originalTransactionId + "/appAccountToken", "PUT", {}, updateAppAccountTokenRequest, null, 'application/json');
await this.makeRequest("/inApps/v1/transactions/" + this.encodePathSegment(originalTransactionId) + "/appAccountToken", "PUT", {}, updateAppAccountTokenRequest, null, 'application/json');
}

/**
Expand All @@ -530,7 +536,7 @@ export class AppStoreServerAPIClient {
if (imageSize != null) {
queryParameters["imageSize"] = [imageSize]
}
await this.makeRequest("/inApps/v1/messaging/image/" + imageIdentifier, "PUT", queryParameters, image, null, 'image/png');
await this.makeRequest("/inApps/v1/messaging/image/" + this.encodePathSegment(imageIdentifier), "PUT", queryParameters, image, null, 'image/png');
}

/**
Expand All @@ -541,7 +547,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/retentionmessaging/delete-image Delete Image}
*/
public async deleteImage(imageIdentifier: string): Promise<void> {
await this.makeRequest("/inApps/v1/messaging/image/" + imageIdentifier, "DELETE", {}, null, null, undefined);
await this.makeRequest("/inApps/v1/messaging/image/" + this.encodePathSegment(imageIdentifier), "DELETE", {}, null, null, undefined);
}

/**
Expand All @@ -564,7 +570,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/retentionmessaging/upload-message Upload Message}
*/
public async uploadMessage(messageIdentifier: string, uploadMessageRequestBody: UploadMessageRequestBody): Promise<void> {
await this.makeRequest("/inApps/v1/messaging/message/" + messageIdentifier, "PUT", {}, uploadMessageRequestBody, null, 'application/json');
await this.makeRequest("/inApps/v1/messaging/message/" + this.encodePathSegment(messageIdentifier), "PUT", {}, uploadMessageRequestBody, null, 'application/json');
}

/**
Expand All @@ -575,7 +581,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/retentionmessaging/delete-message Delete Message}
*/
public async deleteMessage(messageIdentifier: string): Promise<void> {
await this.makeRequest("/inApps/v1/messaging/message/" + messageIdentifier, "DELETE", {}, null, null, undefined);
await this.makeRequest("/inApps/v1/messaging/message/" + this.encodePathSegment(messageIdentifier), "DELETE", {}, null, null, undefined);
}

/**
Expand All @@ -599,7 +605,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/retentionmessaging/configure-default-message Configure Default Message}
*/
public async configureDefaultMessage(productId: string, locale: string, defaultConfigurationRequest: DefaultConfigurationRequest): Promise<void> {
await this.makeRequest("/inApps/v1/messaging/default/" + productId + "/" + locale, "PUT", {}, defaultConfigurationRequest, null, 'application/json');
await this.makeRequest("/inApps/v1/messaging/default/" + this.encodePathSegment(productId) + "/" + this.encodePathSegment(locale), "PUT", {}, defaultConfigurationRequest, null, 'application/json');
}

/**
Expand All @@ -611,7 +617,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/retentionmessaging/delete-default-message Delete Default Message}
*/
public async deleteDefaultMessage(productId: string, locale: string): Promise<void> {
await this.makeRequest("/inApps/v1/messaging/default/" + productId + "/" + locale, "DELETE", {}, null, null, undefined);
await this.makeRequest("/inApps/v1/messaging/default/" + this.encodePathSegment(productId) + "/" + this.encodePathSegment(locale), "DELETE", {}, null, null, undefined);
}

/**
Expand All @@ -624,7 +630,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/retentionmessaging/get-default-message Get Default Message}
*/
public async getDefaultMessage(productId: string, locale: string): Promise<DefaultConfigurationResponse> {
return await this.makeRequest("/inApps/v1/messaging/default/" + productId + "/" + locale, "GET", {}, null, new DefaultConfigurationResponseValidator(), undefined);
return await this.makeRequest("/inApps/v1/messaging/default/" + this.encodePathSegment(productId) + "/" + this.encodePathSegment(locale), "GET", {}, null, new DefaultConfigurationResponseValidator(), undefined);
}

/**
Expand Down Expand Up @@ -680,7 +686,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/retentionmessaging/get-performance-test-results Get Performance Test Results}
*/
public async getPerformanceTestResults(requestId: string): Promise<PerformanceTestResultResponse> {
return await this.makeRequest("/inApps/v1/messaging/performanceTest/result/" + requestId, "GET", {}, null, new PerformanceTestResultResponseValidator(), undefined);
return await this.makeRequest("/inApps/v1/messaging/performanceTest/result/" + this.encodePathSegment(requestId), "GET", {}, null, new PerformanceTestResultResponseValidator(), undefined);
}

/**
Expand All @@ -692,7 +698,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/appstoreserverapi/get-app-transaction-info Get App Transaction Info}
*/
public async getAppTransactionInfo(anyTransactionId: string): Promise<AppTransactionInfoResponse> {
return await this.makeRequest("/inApps/v1/transactions/appTransactions/" + anyTransactionId, "GET", {}, null, new AppTransactionInfoResponseValidator(), undefined);
return await this.makeRequest("/inApps/v1/transactions/appTransactions/" + this.encodePathSegment(anyTransactionId), "GET", {}, null, new AppTransactionInfoResponseValidator(), undefined);
}

/**
Expand All @@ -703,7 +709,7 @@ export class AppStoreServerAPIClient {
* {@link https://developer.apple.com/documentation/appstoreserverapi/finish-transaction Finish Transaction}
*/
public async finishTransaction(transactionId: string): Promise<void> {
await this.makeRequest("/inApps/v1/transactions/" + transactionId + "/finish", "POST", {}, null, null, undefined);
await this.makeRequest("/inApps/v1/transactions/" + this.encodePathSegment(transactionId) + "/finish", "POST", {}, null, null, undefined);
}

private createBearerToken(): string {
Expand Down
50 changes: 49 additions & 1 deletion tests/unit-tests/api_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,54 @@ describe('The api client ', () => {
expect(response.messageIdentifier).toBe("a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890")
})

it('encodes caller-controlled values as URL path segments', async () => {
async function assertPath(expectedPath: string, expectedMethod: string, invoke: (client: AppStoreServerAPIClient) => Promise<void>) {
const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => {
expect(expectedMethod).toBe(method)
expect(expectedPath).toBe(path)
expect(new URL("https://example.test" + path).pathname).toBe(expectedPath)
})

await invoke(client)
}

await assertPath(
"/inApps/v1/messaging/default/product%2Fwith-slash/en-US",
"DELETE",
client => client.deleteDefaultMessage("product/with-slash", "en-US")
)
await assertPath(
"/inApps/v1/messaging/message/message%3Fquery%23fragment",
"DELETE",
client => client.deleteMessage("message?query#fragment")
)
await assertPath(
"/inApps/v1/messaging/image/image%2Fwith-slash",
"DELETE",
client => client.deleteImage("image/with-slash")
)
await assertPath(
"/inApps/v1/messaging/image/%252E%252E",
"DELETE",
client => client.deleteImage("..")
)
await assertPath(
"/inApps/v1/messaging/message/%252E",
"DELETE",
client => client.deleteMessage(".")
)
await assertPath(
"/inApps/v1/transactions/id%3Fstatus%3D1%23fragment/finish",
"POST",
client => client.finishTransaction("id?status=1#fragment")
)
await assertPath(
"/inApps/v1/messaging/default/com.example.product/en-US",
"DELETE",
client => client.deleteDefaultMessage("com.example.product", "en-US")
)
})

it('calls uploadImage with imageSize', async () => {
const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => {
expect("PUT").toBe(method)
Expand Down Expand Up @@ -1103,4 +1151,4 @@ describe('The api client ', () => {

await client.finishTransaction("1234");
})
})
})