Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/rate limit #1

Merged
merged 6 commits into from
Apr 5, 2023
Merged
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
16 changes: 16 additions & 0 deletions Sources/APIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Foundation
import Combine
import Crypto
#if canImport(FoundationNetworking)
import FoundationNetworking
Expand Down Expand Up @@ -131,6 +132,9 @@ public final class APIProvider {

/// The JSON encoder used to encode request parameters.
private let encoder: JSONEncoder

/// The rate limit information from the latest API request
public let rateLimitPublisher = PassthroughSubject<RateLimit, Never>()

/// Creates a new APIProvider instance which can be used to perform API Requests to the App Store Connect API.
///
Expand Down Expand Up @@ -231,6 +235,10 @@ private extension APIProvider {
func mapResponse<T: Decodable>(_ result: Result<Response<Data>, Swift.Error>) -> Result<T, Swift.Error> {
switch result {
case .success(let response):
if let rateLimit = response.rateLimit {
rateLimitPublisher.send(rateLimit)
}

guard let data = response.data, 200..<300 ~= response.statusCode else {
return .failure(Error.requestFailure(response.statusCode, response.errorResponse, response.requestURL))
}
Expand All @@ -257,6 +265,10 @@ private extension APIProvider {
func mapVoidResponse(_ result: Result<Response<Data>, Swift.Error>) -> Result<Void, Swift.Error> {
switch result {
case .success(let response):
if let rateLimit = response.rateLimit {
rateLimitPublisher.send(rateLimit)
}

guard 200..<300 ~= response.statusCode else {
return .failure(Error.requestFailure(response.statusCode, response.errorResponse, response.requestURL))
}
Expand All @@ -274,6 +286,10 @@ private extension APIProvider {
func mapResponse(_ result: Result<Response<URL>, Swift.Error>) -> Result<URL, Swift.Error> {
switch result {
case .success(let response):
if let rateLimit = response.rateLimit {
rateLimitPublisher.send(rateLimit)
}

guard 200..<300 ~= response.statusCode else {
return .failure(Error.requestFailure(response.statusCode, response.errorResponse, response.requestURL))
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/DefaultRequestExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func mapResponse(data: Data?, urlResponse: URLResponse?, error: Error?) -> Resul
return .failure(DefaultRequestExecutor.Error.unknownResponseType)
}

return .success(.init(requestURL: httpUrlResponse.url, statusCode: httpUrlResponse.statusCode, data: data))
return .success(.init(requestURL: httpUrlResponse.url, statusCode: httpUrlResponse.statusCode, allHeaderFields: httpUrlResponse.allHeaderFields, data: data))
}
}

Expand All @@ -83,6 +83,6 @@ func mapResponse(fileUrl: URL?, urlResponse: URLResponse?, error: Error?) -> Res
return .failure(DefaultRequestExecutor.Error.unknownResponseType)
}

return .success(.init(requestURL: httpUrlResponse.url, statusCode: httpUrlResponse.statusCode, data: fileUrl))
return .success(.init(requestURL: httpUrlResponse.url, statusCode: httpUrlResponse.statusCode, allHeaderFields: httpUrlResponse.allHeaderFields, data: fileUrl))
}
}
43 changes: 43 additions & 0 deletions Sources/RateLimit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// RateLimit.swift
//
//
// Created by Mathias Emil Mortensen on 05/04/2023.
//

import Foundation

public struct RateLimit {
/// Number of requests you can make per hour with the same API key.
public let hourlyLimit: Int

/// Number of requests remaining. The time frame is a "rolling hour."
public let remainingInCurrentHour: Int

init?(value: String) {
let components = value.split(separator: ";", omittingEmptySubsequences: true)

guard
let limitString = components.value(for: "user-hour-lim:"),
let remainingString = components.value(for: "user-hour-rem:")
else {
return nil
}

guard
let limit = Int(limitString),
let remaining = Int(remainingString)
else {
return nil
}

hourlyLimit = limit
remainingInCurrentHour = remaining
}
}

private extension Sequence where Element == String.SubSequence {
func value(for key: String) -> String? {
first(where: { $0.contains(key) }).map { $0.replacingOccurrences(of: key, with: "") }
}
}
9 changes: 8 additions & 1 deletion Sources/RequestExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ public struct Response<T> {
public let statusCode: Int
public let data: T?
public let errorResponse: ErrorResponse?
public let rateLimit: RateLimit?

public init(requestURL: URL?, statusCode: StatusCode, data: T?) {
public init(requestURL: URL?, statusCode: StatusCode, allHeaderFields: [AnyHashable: Any], data: T?) {
self.requestURL = requestURL
self.statusCode = statusCode
self.data = data
Expand All @@ -29,6 +30,12 @@ public struct Response<T> {
} else {
self.errorResponse = nil
}

if let rateLimitValue = allHeaderFields["X-Rate-Limit"] as? String {
self.rateLimit = RateLimit(value: rateLimitValue)
} else {
self.rateLimit = nil
}
}
}

Expand Down
10 changes: 5 additions & 5 deletions Tests/APIProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ final class APIProviderTests: XCTestCase {
// MARK: - Tests

func testRequestExecutionWithVoidResponse() {
let response = Response<Data>(requestURL: nil, statusCode: 200, data: nil)
let response = Response<Data>(requestURL: nil, statusCode: 200, allHeaderFields: [:], data: nil)
let mockRequestExecutor = MockRequestExecutor(expectedResponse: Result.success(response))
let apiProvider = APIProvider(configuration: configuration, requestExecutor: mockRequestExecutor)

Expand All @@ -74,7 +74,7 @@ final class APIProviderTests: XCTestCase {
)
])
let responseData = try JSONEncoder().encode(errorResponse)
let response = Response<Data>(requestURL: expectedURL, statusCode: 404, data: responseData)
let response = Response<Data>(requestURL: expectedURL, statusCode: 404, allHeaderFields: [:], data: responseData)
let mockRequestExecutor = MockRequestExecutor(expectedResponse: Result.success(response))
let apiProvider = APIProvider(configuration: configuration, requestExecutor: mockRequestExecutor)

Expand Down Expand Up @@ -102,7 +102,7 @@ final class APIProviderTests: XCTestCase {
}

func testDownloadRequestWithResultSuccess() {
let response = Response(requestURL: nil, statusCode: 200, data: URL(fileURLWithPath: "randompath"))
let response = Response(requestURL: nil, statusCode: 200, allHeaderFields: [:], data: URL(fileURLWithPath: "randompath"))
let mockRequestExecutor = MockRequestExecutor(expectedResponse: Result.success(response))

let apiProvider = APIProvider(configuration: configuration, requestExecutor: mockRequestExecutor)
Expand All @@ -119,7 +119,7 @@ final class APIProviderTests: XCTestCase {
}

func testDownloadRequestWithProblemOnFileCreation() {
let response = Response<URL>(requestURL: nil, statusCode: 200, data: nil)
let response = Response<URL>(requestURL: nil, statusCode: 200, allHeaderFields: [:], data: nil)
let mockRequestExecutor = MockRequestExecutor(expectedResponse: Result.success(response))

let apiProvider = APIProvider(configuration: configuration, requestExecutor: mockRequestExecutor)
Expand All @@ -141,7 +141,7 @@ final class APIProviderTests: XCTestCase {
}

func testDownloadRequestWithFailure() {
let response = Response<URL>(requestURL: nil, statusCode: 500, data: nil)
let response = Response<URL>(requestURL: nil, statusCode: 500, allHeaderFields: [:], data: nil)
let mockRequestExecutor = MockRequestExecutor(expectedResponse: Result.success(response))

let apiProvider = APIProvider(configuration: configuration, requestExecutor: mockRequestExecutor)
Expand Down
35 changes: 35 additions & 0 deletions Tests/RateLimitTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// RateLimitTests.swift
//
//
// Created by Mathias Emil Mortensen on 05/04/2023.
//

import XCTest
@testable import AppStoreConnect_Swift_SDK

final class RateLimitTests: XCTestCase {

func testValidValue() {
let rateLimit = RateLimit(value: "user-hour-lim:3600;user-hour-rem:3545;")
XCTAssertNotNil(rateLimit)
if let rateLimit {
XCTAssertEqual(rateLimit.hourlyLimit, 3600)
XCTAssertEqual(rateLimit.remainingInCurrentHour, 3545)
}
}

func testInvalidValue() {
let rateLimit = RateLimit(value: "user-hour-rem:3545")
XCTAssertNil(rateLimit)
}

func testModifiedValue() {
let rateLimit = RateLimit(value: "user-hour-rem:0;user-hour-lim:50;user-hour-new-value:10;")
XCTAssertNotNil(rateLimit)
if let rateLimit {
XCTAssertEqual(rateLimit.hourlyLimit, 50)
XCTAssertEqual(rateLimit.remainingInCurrentHour, 0)
}
}
}