Added support for Buildkite's JSON errors, and wrote more tests for pagination
This commit is contained in:
parent
2865672821
commit
9481c26290
2
LICENSE
2
LICENSE
|
@ -6,4 +6,4 @@ Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
|
||||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
|
@ -49,7 +49,7 @@ public final class Buildkite {
|
||||||
transport.send(request: request, completion: handleContentfulResponse(completion: completion))
|
transport.send(request: request, completion: handleContentfulResponse(completion: completion))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func send<R: Resource & HasResponseBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil, completion: @escaping (Result<Response<R.Content>, Error>) -> Void) {
|
public func send<R: Resource & Paginated>(_ resource: R, pageOptions: PageOptions? = nil, completion: @escaping (Result<Response<R.Content>, Error>) -> Void) {
|
||||||
let request = URLRequest(resource, configuration: configuration, pageOptions: pageOptions)
|
let request = URLRequest(resource, configuration: configuration, pageOptions: pageOptions)
|
||||||
transport.send(request: request, completion: handleContentfulResponse(completion: completion))
|
transport.send(request: request, completion: handleContentfulResponse(completion: completion))
|
||||||
}
|
}
|
||||||
|
@ -65,7 +65,7 @@ public final class Buildkite {
|
||||||
transport.send(request: request, completion: handleContentfulResponse(completion: completion))
|
transport.send(request: request, completion: handleContentfulResponse(completion: completion))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func send<R: Resource & HasRequestBody & HasResponseBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil, completion: @escaping (Result<Response<R.Content>, Error>) -> Void) {
|
public func send<R: Resource & HasRequestBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil, completion: @escaping (Result<Response<R.Content>, Error>) -> Void) {
|
||||||
let request: URLRequest
|
let request: URLRequest
|
||||||
do {
|
do {
|
||||||
request = try URLRequest(resource, configuration: configuration, encoder: encoder, pageOptions: pageOptions)
|
request = try URLRequest(resource, configuration: configuration, encoder: encoder, pageOptions: pageOptions)
|
||||||
|
@ -92,17 +92,6 @@ public final class Buildkite {
|
||||||
transport.send(request: request, completion: handleEmptyResponse(completion: completion))
|
transport.send(request: request, completion: handleEmptyResponse(completion: completion))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func send<R: Resource & HasRequestBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil, completion: @escaping (Result<Response<Void>, Error>) -> Void) {
|
|
||||||
let request: URLRequest
|
|
||||||
do {
|
|
||||||
request = try URLRequest(resource, configuration: configuration, encoder: encoder, pageOptions: pageOptions)
|
|
||||||
} catch {
|
|
||||||
completion(.failure(error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
transport.send(request: request, completion: handleEmptyResponse(completion: completion))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleContentfulResponse<Content: Decodable>(completion: @escaping (Result<Response<Content>, Error>) -> Void) -> Transport.Completion {
|
private func handleContentfulResponse<Content: Decodable>(completion: @escaping (Result<Response<Content>, Error>) -> Void) -> Transport.Completion {
|
||||||
return { [weak self] result in
|
return { [weak self] result in
|
||||||
guard let self = self else {
|
guard let self = self else {
|
||||||
|
@ -113,7 +102,7 @@ public final class Buildkite {
|
||||||
do {
|
do {
|
||||||
let data: Data
|
let data: Data
|
||||||
(data, response) = try result.get()
|
(data, response) = try result.get()
|
||||||
try self.checkResponseForIssues(response)
|
try self.checkResponseForIssues(response, data: data)
|
||||||
content = try self.decoder.decode(Content.self, from: data)
|
content = try self.decoder.decode(Content.self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
|
@ -140,13 +129,18 @@ public final class Buildkite {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkResponseForIssues(_ response: URLResponse) throws {
|
private func checkResponseForIssues(_ response: URLResponse, data: Data? = nil) throws {
|
||||||
guard let response = response as? HTTPURLResponse,
|
guard let response = response as? HTTPURLResponse,
|
||||||
let statusCode = StatusCode(rawValue: response.statusCode) else {
|
let statusCode = StatusCode(rawValue: response.statusCode) else {
|
||||||
throw ResponseError.missingResponse
|
throw ResponseError.missingResponse
|
||||||
}
|
}
|
||||||
if !statusCode.isSuccess {
|
if !statusCode.isSuccess {
|
||||||
throw statusCode
|
if let data = data,
|
||||||
|
let errorIntermediary = try? decoder.decode(BuildkiteError.Intermediary.self, from: data) {
|
||||||
|
throw BuildkiteError(statusCode: statusCode, intermediary: errorIntermediary)
|
||||||
|
} else {
|
||||||
|
throw statusCode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -159,7 +153,7 @@ extension Buildkite {
|
||||||
public func sendPublisher<R: Resource & HasResponseBody>(_ resource: R) -> AnyPublisher<Response<R.Content>, Error> {
|
public func sendPublisher<R: Resource & HasResponseBody>(_ resource: R) -> AnyPublisher<Response<R.Content>, Error> {
|
||||||
transport.sendPublisher(request: URLRequest(resource, configuration: configuration))
|
transport.sendPublisher(request: URLRequest(resource, configuration: configuration))
|
||||||
.tryMap {
|
.tryMap {
|
||||||
try self.checkResponseForIssues($0.response)
|
try self.checkResponseForIssues($0.response, data: $0.data)
|
||||||
let content = try self.decoder.decode(R.Content.self, from: $0.data)
|
let content = try self.decoder.decode(R.Content.self, from: $0.data)
|
||||||
return Response(content: content, response: $0.response)
|
return Response(content: content, response: $0.response)
|
||||||
}
|
}
|
||||||
|
@ -169,7 +163,7 @@ extension Buildkite {
|
||||||
public func sendPublisher<R: Resource & HasResponseBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil) -> AnyPublisher<Response<R.Content>, Error> {
|
public func sendPublisher<R: Resource & HasResponseBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil) -> AnyPublisher<Response<R.Content>, Error> {
|
||||||
transport.sendPublisher(request: URLRequest(resource, configuration: configuration, pageOptions: pageOptions))
|
transport.sendPublisher(request: URLRequest(resource, configuration: configuration, pageOptions: pageOptions))
|
||||||
.tryMap {
|
.tryMap {
|
||||||
try self.checkResponseForIssues($0.response)
|
try self.checkResponseForIssues($0.response, data: $0.data)
|
||||||
let content = try self.decoder.decode(R.Content.self, from: $0.data)
|
let content = try self.decoder.decode(R.Content.self, from: $0.data)
|
||||||
return Response(content: content, response: $0.response)
|
return Response(content: content, response: $0.response)
|
||||||
}
|
}
|
||||||
|
@ -181,19 +175,19 @@ extension Buildkite {
|
||||||
.publisher
|
.publisher
|
||||||
.flatMap(transport.sendPublisher)
|
.flatMap(transport.sendPublisher)
|
||||||
.tryMap {
|
.tryMap {
|
||||||
try self.checkResponseForIssues($0.response)
|
try self.checkResponseForIssues($0.response, data: $0.data)
|
||||||
let content = try self.decoder.decode(R.Content.self, from: $0.data)
|
let content = try self.decoder.decode(R.Content.self, from: $0.data)
|
||||||
return Response(content: content, response: $0.response)
|
return Response(content: content, response: $0.response)
|
||||||
}
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sendPublisher<R: Resource & HasRequestBody & HasResponseBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil) -> AnyPublisher<Response<R.Content>, Error> {
|
public func sendPublisher<R: Resource & HasRequestBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil) -> AnyPublisher<Response<R.Content>, Error> {
|
||||||
Result { try URLRequest(resource, configuration: configuration, encoder: encoder, pageOptions: pageOptions) }
|
Result { try URLRequest(resource, configuration: configuration, encoder: encoder, pageOptions: pageOptions) }
|
||||||
.publisher
|
.publisher
|
||||||
.flatMap(transport.sendPublisher)
|
.flatMap(transport.sendPublisher)
|
||||||
.tryMap {
|
.tryMap {
|
||||||
try self.checkResponseForIssues($0.response)
|
try self.checkResponseForIssues($0.response, data: $0.data)
|
||||||
let content = try self.decoder.decode(R.Content.self, from: $0.data)
|
let content = try self.decoder.decode(R.Content.self, from: $0.data)
|
||||||
return Response(content: content, response: $0.response)
|
return Response(content: content, response: $0.response)
|
||||||
}
|
}
|
||||||
|
@ -219,16 +213,5 @@ extension Buildkite {
|
||||||
}
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPublisher<R: Resource & HasRequestBody & Paginated>(_ resource: R, pageOptions: PageOptions? = nil) -> AnyPublisher<Response<Void>, Error> {
|
|
||||||
Result { try URLRequest(resource, configuration: configuration, encoder: encoder, pageOptions: pageOptions) }
|
|
||||||
.publisher
|
|
||||||
.flatMap(transport.sendPublisher)
|
|
||||||
.tryMap {
|
|
||||||
try self.checkResponseForIssues($0.response)
|
|
||||||
return Response(content: (), response: $0.response)
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -127,7 +127,7 @@ public enum Job: Codable, Equatable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct LogOutput: Codable {
|
public struct LogOutput: Codable, Equatable {
|
||||||
public var url: URL // Resource<Job.Resources.GetLogOutput>
|
public var url: URL // Resource<Job.Resources.GetLogOutput>
|
||||||
public var content: String
|
public var content: String
|
||||||
public var size: Int
|
public var size: Int
|
||||||
|
|
|
@ -17,6 +17,23 @@ enum ResponseError: Error {
|
||||||
case unexpectedlyNoContent
|
case unexpectedlyNoContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct BuildkiteError: Error {
|
||||||
|
public var statusCode: StatusCode
|
||||||
|
public var message: String
|
||||||
|
public var errors: [String]
|
||||||
|
|
||||||
|
init(statusCode: StatusCode, intermediary: Intermediary) {
|
||||||
|
self.statusCode = statusCode
|
||||||
|
self.message = intermediary.message ?? ""
|
||||||
|
self.errors = intermediary.errors ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Intermediary: Codable {
|
||||||
|
var message: String?
|
||||||
|
var errors: [String]?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct Response<T> {
|
public struct Response<T> {
|
||||||
public let content: T
|
public let content: T
|
||||||
public let response: URLResponse
|
public let response: URLResponse
|
||||||
|
|
|
@ -12,7 +12,7 @@ import Foundation
|
||||||
import FoundationNetworking
|
import FoundationNetworking
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public enum StatusCode: Int, Error {
|
public enum StatusCode: Int, Error, Codable {
|
||||||
/// The request was successfully processed by Buildkite.
|
/// The request was successfully processed by Buildkite.
|
||||||
case ok = 200
|
case ok = 200
|
||||||
|
|
||||||
|
@ -22,6 +22,9 @@ public enum StatusCode: Int, Error {
|
||||||
/// The request has been accepted, but not yet processed.
|
/// The request has been accepted, but not yet processed.
|
||||||
case accepted = 202
|
case accepted = 202
|
||||||
|
|
||||||
|
/// The request was found
|
||||||
|
case found = 302
|
||||||
|
|
||||||
/// The response to the request can be found under a different URL in the Location header and can be retrieved using a GET method on that resource.
|
/// The response to the request can be found under a different URL in the Location header and can be retrieved using a GET method on that resource.
|
||||||
case seeOther = 303
|
case seeOther = 303
|
||||||
|
|
||||||
|
@ -49,8 +52,6 @@ public enum StatusCode: Int, Error {
|
||||||
case unprocessableEntity = 422
|
case unprocessableEntity = 422
|
||||||
|
|
||||||
/// The requested shop is currently locked. Shops are locked if they repeatedly exceed their API request limit, or if there is an issue with the account, such as a detected compromise or fraud risk.
|
/// The requested shop is currently locked. Shops are locked if they repeatedly exceed their API request limit, or if there is an issue with the account, such as a detected compromise or fraud risk.
|
||||||
///
|
|
||||||
/// Contact support if your shop is locked.
|
|
||||||
case locked = 423
|
case locked = 423
|
||||||
|
|
||||||
/// The request was not accepted because the application has exceeded the rate limit. See the API Call Limit documentation for a breakdown of Buildkite's rate-limiting mechanism.
|
/// The request was not accepted because the application has exceeded the rate limit. See the API Call Limit documentation for a breakdown of Buildkite's rate-limiting mechanism.
|
||||||
|
@ -72,5 +73,6 @@ public enum StatusCode: Int, Error {
|
||||||
self == .ok
|
self == .ok
|
||||||
|| self == .created
|
|| self == .created
|
||||||
|| self == .accepted
|
|| self == .accepted
|
||||||
|
|| self == .found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,11 +30,11 @@ extension Job.Resources {
|
||||||
public var build: Int
|
public var build: Int
|
||||||
/// job ID
|
/// job ID
|
||||||
public var job: UUID
|
public var job: UUID
|
||||||
|
|
||||||
public var path: String {
|
public var path: String {
|
||||||
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(job)/retry"
|
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(job)/retry"
|
||||||
}
|
}
|
||||||
|
|
||||||
public func transformRequest(_ request: inout URLRequest) {
|
public func transformRequest(_ request: inout URLRequest) {
|
||||||
request.httpMethod = "PUT"
|
request.httpMethod = "PUT"
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,7 @@ extension Job.Resources {
|
||||||
self.job = job
|
self.job = job
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unblock a job
|
/// Unblock a job
|
||||||
///
|
///
|
||||||
/// Unblocks a build’s "Block pipeline" job. The job’s `unblockable` property indicates whether it is able to be unblocked, and the `unblock_url` property points to this endpoint.
|
/// Unblocks a build’s "Block pipeline" job. The job’s `unblockable` property indicates whether it is able to be unblocked, and the `unblock_url` property points to this endpoint.
|
||||||
|
@ -62,7 +62,7 @@ extension Job.Resources {
|
||||||
public var job: UUID
|
public var job: UUID
|
||||||
/// body of the request
|
/// body of the request
|
||||||
public var body: Body
|
public var body: Body
|
||||||
|
|
||||||
public struct Body: Codable {
|
public struct Body: Codable {
|
||||||
public var unblocker: UUID?
|
public var unblocker: UUID?
|
||||||
public var fields: [String: String]
|
public var fields: [String: String]
|
||||||
|
@ -72,7 +72,7 @@ extension Job.Resources {
|
||||||
self.fields = fields
|
self.fields = fields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var path: String {
|
public var path: String {
|
||||||
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(job)/unblock"
|
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(job)/unblock"
|
||||||
}
|
}
|
||||||
|
@ -84,15 +84,15 @@ extension Job.Resources {
|
||||||
self.job = job
|
self.job = job
|
||||||
self.body = body
|
self.body = body
|
||||||
}
|
}
|
||||||
|
|
||||||
public func transformRequest(_ request: inout URLRequest) {
|
public func transformRequest(_ request: inout URLRequest) {
|
||||||
request.httpMethod = "PUT"
|
request.httpMethod = "PUT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a job’s log output
|
/// Get a job’s log output
|
||||||
public struct LogOutput: Resource {
|
public struct LogOutput: Resource, HasResponseBody {
|
||||||
typealias Content = Job.LogOutput
|
public typealias Content = Job.LogOutput
|
||||||
/// organization slug
|
/// organization slug
|
||||||
public var organization: String
|
public var organization: String
|
||||||
/// pipeline slug
|
/// pipeline slug
|
||||||
|
@ -101,7 +101,7 @@ extension Job.Resources {
|
||||||
public var build: Int
|
public var build: Int
|
||||||
/// job ID
|
/// job ID
|
||||||
public var job: UUID
|
public var job: UUID
|
||||||
|
|
||||||
public var path: String {
|
public var path: String {
|
||||||
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(job)/log"
|
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(job)/log"
|
||||||
}
|
}
|
||||||
|
@ -113,7 +113,7 @@ extension Job.Resources {
|
||||||
self.job = job
|
self.job = job
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a job’s log output
|
/// Delete a job’s log output
|
||||||
public struct DeleteLogOutput: Resource {
|
public struct DeleteLogOutput: Resource {
|
||||||
public typealias Content = Void
|
public typealias Content = Void
|
||||||
|
@ -125,7 +125,7 @@ extension Job.Resources {
|
||||||
public var build: Int
|
public var build: Int
|
||||||
/// job ID
|
/// job ID
|
||||||
public var job: UUID
|
public var job: UUID
|
||||||
|
|
||||||
public var path: String {
|
public var path: String {
|
||||||
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(job)/log"
|
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(job)/log"
|
||||||
}
|
}
|
||||||
|
@ -136,12 +136,12 @@ extension Job.Resources {
|
||||||
self.build = build
|
self.build = build
|
||||||
self.job = job
|
self.job = job
|
||||||
}
|
}
|
||||||
|
|
||||||
public func transformRequest(_ request: inout URLRequest) {
|
public func transformRequest(_ request: inout URLRequest) {
|
||||||
request.httpMethod = "DELETE"
|
request.httpMethod = "DELETE"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a job's environment variables
|
/// Get a job's environment variables
|
||||||
public struct EnvironmentVariables: Resource, HasResponseBody {
|
public struct EnvironmentVariables: Resource, HasResponseBody {
|
||||||
public typealias Content = Job.EnvironmentVariables
|
public typealias Content = Job.EnvironmentVariables
|
||||||
|
@ -153,7 +153,7 @@ extension Job.Resources {
|
||||||
public var build: Int
|
public var build: Int
|
||||||
/// job ID
|
/// job ID
|
||||||
public var job: UUID
|
public var job: UUID
|
||||||
|
|
||||||
public var path: String {
|
public var path: String {
|
||||||
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(job)/env"
|
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(job)/env"
|
||||||
}
|
}
|
||||||
|
@ -166,3 +166,36 @@ extension Job.Resources {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Job.Resources.LogOutput {
|
||||||
|
public struct Alternative: Resource, HasResponseBody {
|
||||||
|
public enum Format: String {
|
||||||
|
case html
|
||||||
|
case plainText = "txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
public typealias Content = String
|
||||||
|
/// organization slug
|
||||||
|
public var organization: String
|
||||||
|
/// pipeline slug
|
||||||
|
public var pipeline: String
|
||||||
|
/// build number
|
||||||
|
public var build: Int
|
||||||
|
/// job ID
|
||||||
|
public var job: UUID
|
||||||
|
|
||||||
|
public var format: Format
|
||||||
|
|
||||||
|
public var path: String {
|
||||||
|
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(job)/log.\(format)"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(organization: String, pipeline: String, build: Int, job: UUID, format: Format) {
|
||||||
|
self.organization = organization
|
||||||
|
self.pipeline = pipeline
|
||||||
|
self.build = build
|
||||||
|
self.job = job
|
||||||
|
self.format = format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ extension Team.Resources {
|
||||||
public typealias Content = [Team]
|
public typealias Content = [Team]
|
||||||
/// organization slug
|
/// organization slug
|
||||||
public var organization: String
|
public var organization: String
|
||||||
|
/// Filters the results to teams that have the given user as a member.
|
||||||
public var userId: UUID?
|
public var userId: UUID?
|
||||||
|
|
||||||
public var path: String {
|
public var path: String {
|
||||||
|
|
|
@ -22,24 +22,33 @@ class BuildkiteTests: XCTestCase {
|
||||||
private struct TestData {
|
private struct TestData {
|
||||||
enum Case {
|
enum Case {
|
||||||
case success
|
case success
|
||||||
|
case successPaginated
|
||||||
case successNoContent
|
case successNoContent
|
||||||
|
case successHasBody
|
||||||
|
case successHasBodyPaginated
|
||||||
case badResponse
|
case badResponse
|
||||||
case unsuccessfulResponse
|
case unsuccessfulResponse
|
||||||
case noData
|
case noData
|
||||||
}
|
}
|
||||||
|
|
||||||
var configuration = Configuration.default
|
var configuration = Configuration.default
|
||||||
var client: Buildkite
|
var client: Buildkite
|
||||||
var resources = MockResources()
|
var resources = MockResources()
|
||||||
|
|
||||||
init(testCase: Case = .success) throws {
|
init(testCase: Case = .success) throws {
|
||||||
let responses: [(Data, URLResponse)]
|
let responses: [(Data, URLResponse)]
|
||||||
|
|
||||||
switch testCase {
|
switch testCase {
|
||||||
case .success:
|
case .success:
|
||||||
responses = [try MockData.mockingSuccess(with: resources.content, url: configuration.baseURL)]
|
responses = [try MockData.mockingSuccess(with: resources.content, url: configuration.baseURL)]
|
||||||
|
case .successPaginated:
|
||||||
|
responses = [try MockData.mockingSuccess(with: resources.paginatedContent, url: configuration.baseURL)]
|
||||||
case .successNoContent:
|
case .successNoContent:
|
||||||
responses = [MockData.mockingSuccessNoContent(url: configuration.baseURL)]
|
responses = [MockData.mockingSuccessNoContent(url: configuration.baseURL)]
|
||||||
|
case .successHasBody:
|
||||||
|
responses = [MockData.mockingSuccessNoContent(url: configuration.baseURL)]
|
||||||
|
case .successHasBodyPaginated:
|
||||||
|
responses = [try MockData.mockingSuccess(with: resources.bodyAndPaginatedContent, url: configuration.baseURL)]
|
||||||
case .badResponse:
|
case .badResponse:
|
||||||
responses = [MockData.mockingIncompatibleResponse(for: configuration.baseURL)]
|
responses = [MockData.mockingIncompatibleResponse(for: configuration.baseURL)]
|
||||||
case .unsuccessfulResponse:
|
case .unsuccessfulResponse:
|
||||||
|
@ -47,7 +56,7 @@ class BuildkiteTests: XCTestCase {
|
||||||
case .noData:
|
case .noData:
|
||||||
responses = []
|
responses = []
|
||||||
}
|
}
|
||||||
|
|
||||||
client = Buildkite(configuration: configuration,
|
client = Buildkite(configuration: configuration,
|
||||||
transport: MockTransport(responses: responses))
|
transport: MockTransport(responses: responses))
|
||||||
}
|
}
|
||||||
|
@ -59,7 +68,7 @@ class BuildkiteTests: XCTestCase {
|
||||||
extension BuildkiteTests {
|
extension BuildkiteTests {
|
||||||
func testClosureBasedRequest() throws {
|
func testClosureBasedRequest() throws {
|
||||||
let testData = try TestData(testCase: .success)
|
let testData = try TestData(testCase: .success)
|
||||||
|
|
||||||
let expectation = XCTestExpectation()
|
let expectation = XCTestExpectation()
|
||||||
testData.client.send(testData.resources.contentResource) { result in
|
testData.client.send(testData.resources.contentResource) { result in
|
||||||
do {
|
do {
|
||||||
|
@ -72,17 +81,61 @@ extension BuildkiteTests {
|
||||||
}
|
}
|
||||||
wait(for: [expectation])
|
wait(for: [expectation])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testClosureBasedRequestNoContent() throws {
|
func testClosureBasedRequestWithPagination() throws {
|
||||||
let testData = try TestData(testCase: .successNoContent)
|
let testData = try TestData(testCase: .success)
|
||||||
|
|
||||||
let expectation = XCTestExpectation()
|
let expectation = XCTestExpectation()
|
||||||
testData.client.send(testData.resources.noContentResource) { _ in
|
testData.client.send(testData.resources.paginatedContentResource, pageOptions: PageOptions(page: 1, perPage: 30)) { result in
|
||||||
|
do {
|
||||||
|
let response = try result.get()
|
||||||
|
XCTAssertEqual(testData.resources.paginatedContent, response.content)
|
||||||
|
XCTAssertNotNil(response.page)
|
||||||
|
} catch {
|
||||||
|
XCTFail(error.localizedDescription)
|
||||||
|
}
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
wait(for: [expectation])
|
wait(for: [expectation])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testClosureBasedRequestNoContent() throws {
|
||||||
|
let testData = try TestData(testCase: .successNoContent)
|
||||||
|
|
||||||
|
let expectation = XCTestExpectation()
|
||||||
|
testData.client.send(testData.resources.noContentNoBodyResource) { _ in
|
||||||
|
expectation.fulfill()
|
||||||
|
}
|
||||||
|
wait(for: [expectation])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClosureBasedRequestHasBody() throws {
|
||||||
|
let testData = try TestData(testCase: .successHasBody)
|
||||||
|
|
||||||
|
let expectation = XCTestExpectation()
|
||||||
|
testData.client.send(testData.resources.bodyResource) { _ in
|
||||||
|
expectation.fulfill()
|
||||||
|
}
|
||||||
|
wait(for: [expectation])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClosureBasedRequestHasBodyWithPagination() throws {
|
||||||
|
let testData = try TestData(testCase: .successHasBodyPaginated)
|
||||||
|
|
||||||
|
let expectation = XCTestExpectation()
|
||||||
|
testData.client.send(testData.resources.bodyAndPaginatedResource, pageOptions: PageOptions(page: 1, perPage: 30)) { result in
|
||||||
|
do {
|
||||||
|
let response = try result.get()
|
||||||
|
XCTAssertEqual(testData.resources.bodyAndPaginatedContent, response.content)
|
||||||
|
XCTAssertNotNil(response.page)
|
||||||
|
} catch {
|
||||||
|
XCTFail(error.localizedDescription)
|
||||||
|
}
|
||||||
|
expectation.fulfill()
|
||||||
|
}
|
||||||
|
wait(for: [expectation])
|
||||||
|
}
|
||||||
|
|
||||||
func testClosureBasedRequestInvalidResponse() throws {
|
func testClosureBasedRequestInvalidResponse() throws {
|
||||||
let testData = try TestData(testCase: .badResponse)
|
let testData = try TestData(testCase: .badResponse)
|
||||||
let expectation = XCTestExpectation()
|
let expectation = XCTestExpectation()
|
||||||
|
@ -96,7 +149,7 @@ extension BuildkiteTests {
|
||||||
}
|
}
|
||||||
wait(for: [expectation])
|
wait(for: [expectation])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testClosureBasedRequestUnsuccessfulResponse() throws {
|
func testClosureBasedRequestUnsuccessfulResponse() throws {
|
||||||
let testData = try TestData(testCase: .unsuccessfulResponse)
|
let testData = try TestData(testCase: .unsuccessfulResponse)
|
||||||
let expectation = XCTestExpectation()
|
let expectation = XCTestExpectation()
|
||||||
|
@ -111,7 +164,7 @@ extension BuildkiteTests {
|
||||||
}
|
}
|
||||||
wait(for: [expectation])
|
wait(for: [expectation])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFailureFromTransport() throws {
|
func testFailureFromTransport() throws {
|
||||||
let testData = try TestData(testCase: .noData)
|
let testData = try TestData(testCase: .noData)
|
||||||
let expectation = XCTestExpectation()
|
let expectation = XCTestExpectation()
|
||||||
|
@ -148,12 +201,31 @@ extension BuildkiteTests {
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
wait(for: [expectation])
|
wait(for: [expectation])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPublisherBasedRequestWithPagination() throws {
|
||||||
|
let testData = try TestData(testCase: .success)
|
||||||
|
let expectation = XCTestExpectation()
|
||||||
|
var cancellables: Set<AnyCancellable> = []
|
||||||
|
testData.client.sendPublisher(testData.resources.paginatedContentResource, pageOptions: PageOptions(page: 1, perPage: 30))
|
||||||
|
.sink(receiveCompletion: {
|
||||||
|
if case let .failure(error) = $0 {
|
||||||
|
XCTFail(error.localizedDescription)
|
||||||
|
}
|
||||||
|
expectation.fulfill()
|
||||||
|
},
|
||||||
|
receiveValue: {
|
||||||
|
XCTAssertEqual(testData.resources.paginatedContent, $0.content)
|
||||||
|
XCTAssertNotNil($0.page)
|
||||||
|
})
|
||||||
|
.store(in: &cancellables)
|
||||||
|
wait(for: [expectation])
|
||||||
|
}
|
||||||
|
|
||||||
func testPublisherBasedRequestNoContent() throws {
|
func testPublisherBasedRequestNoContent() throws {
|
||||||
let testData = try TestData(testCase: .success)
|
let testData = try TestData(testCase: .success)
|
||||||
let expectation = XCTestExpectation()
|
let expectation = XCTestExpectation()
|
||||||
var cancellables: Set<AnyCancellable> = []
|
var cancellables: Set<AnyCancellable> = []
|
||||||
testData.client.sendPublisher(testData.resources.noContentResource)
|
testData.client.sendPublisher(testData.resources.noContentNoBodyResource)
|
||||||
.sink(receiveCompletion: {
|
.sink(receiveCompletion: {
|
||||||
if case let .failure(error) = $0 {
|
if case let .failure(error) = $0 {
|
||||||
XCTFail(error.localizedDescription)
|
XCTFail(error.localizedDescription)
|
||||||
|
@ -163,7 +235,41 @@ extension BuildkiteTests {
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
wait(for: [expectation])
|
wait(for: [expectation])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPublisherBasedRequestHasBody() throws {
|
||||||
|
let testData = try TestData(testCase: .successHasBody)
|
||||||
|
let expectation = XCTestExpectation()
|
||||||
|
var cancellables: Set<AnyCancellable> = []
|
||||||
|
testData.client.sendPublisher(testData.resources.bodyResource)
|
||||||
|
.sink(receiveCompletion: {
|
||||||
|
if case let .failure(error) = $0 {
|
||||||
|
XCTFail(error.localizedDescription)
|
||||||
|
}
|
||||||
|
expectation.fulfill()
|
||||||
|
}, receiveValue: { _ in })
|
||||||
|
.store(in: &cancellables)
|
||||||
|
wait(for: [expectation])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPublisherBasedRequestHasBodyWithPagination() throws {
|
||||||
|
let testData = try TestData(testCase: .successHasBodyPaginated)
|
||||||
|
let expectation = XCTestExpectation()
|
||||||
|
var cancellables: Set<AnyCancellable> = []
|
||||||
|
testData.client.sendPublisher(testData.resources.bodyAndPaginatedResource, pageOptions: PageOptions(page: 1, perPage: 30))
|
||||||
|
.sink(receiveCompletion: {
|
||||||
|
if case let .failure(error) = $0 {
|
||||||
|
XCTFail(error.localizedDescription)
|
||||||
|
}
|
||||||
|
expectation.fulfill()
|
||||||
|
},
|
||||||
|
receiveValue: {
|
||||||
|
XCTAssertEqual(testData.resources.bodyAndPaginatedContent, $0.content)
|
||||||
|
XCTAssertNotNil($0.page)
|
||||||
|
})
|
||||||
|
.store(in: &cancellables)
|
||||||
|
wait(for: [expectation])
|
||||||
|
}
|
||||||
|
|
||||||
func testPublisherBasedRequestInvalidResponse() throws {
|
func testPublisherBasedRequestInvalidResponse() throws {
|
||||||
let testData = try TestData(testCase: .badResponse)
|
let testData = try TestData(testCase: .badResponse)
|
||||||
let expectation = XCTestExpectation()
|
let expectation = XCTestExpectation()
|
||||||
|
@ -178,7 +284,7 @@ extension BuildkiteTests {
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
wait(for: [expectation])
|
wait(for: [expectation])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPublisherBasedRequestUnsuccessfulResponse() throws {
|
func testPublisherBasedRequestUnsuccessfulResponse() throws {
|
||||||
let testData = try TestData(testCase: .unsuccessfulResponse)
|
let testData = try TestData(testCase: .unsuccessfulResponse)
|
||||||
let expectation = XCTestExpectation()
|
let expectation = XCTestExpectation()
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
//
|
|
||||||
// PaginationTests.swift
|
|
||||||
// Buildkite
|
|
||||||
//
|
|
||||||
// Created by Aaron Sky on 5/7/20.
|
|
||||||
// Copyright © 2020 Aaron Sky. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import XCTest
|
|
||||||
@testable import Buildkite
|
|
||||||
|
|
||||||
#if canImport(FoundationNetworking)
|
|
||||||
import FoundationNetworking
|
|
||||||
#endif
|
|
||||||
|
|
||||||
class PaginationTests: XCTestCase {
|
|
||||||
func testPagination() throws {
|
|
||||||
let expected = [Pipeline(), Pipeline()]
|
|
||||||
let context = try MockContext(content: expected)
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation()
|
|
||||||
|
|
||||||
context.client.send(Pipeline.Resources.List(organization: "buildkite"), pageOptions: PageOptions(page: 1, perPage: 30)) { result in
|
|
||||||
do {
|
|
||||||
let response = try result.get()
|
|
||||||
XCTAssertEqual(expected, response.content)
|
|
||||||
} catch {
|
|
||||||
XCTFail(error.localizedDescription)
|
|
||||||
}
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
wait(for: [expectation])
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -55,13 +55,51 @@ class JobsTests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
func testJobsLogOutput() throws {
|
func testJobsLogOutput() throws {
|
||||||
let context = MockContext()
|
let expected = Job.LogOutput()
|
||||||
|
let context = try MockContext(content: expected)
|
||||||
|
|
||||||
let expectation = XCTestExpectation()
|
let expectation = XCTestExpectation()
|
||||||
|
|
||||||
context.client.send(Job.Resources.LogOutput(organization: "buildkite", pipeline: "my-pipeline", build: 1, job: UUID())) { result in
|
context.client.send(Job.Resources.LogOutput(organization: "buildkite", pipeline: "my-pipeline", build: 1, job: UUID())) { result in
|
||||||
do {
|
do {
|
||||||
_ = try result.get()
|
let response = try result.get()
|
||||||
|
XCTAssertEqual(expected, response.content)
|
||||||
|
} catch {
|
||||||
|
XCTFail(error.localizedDescription)
|
||||||
|
}
|
||||||
|
expectation.fulfill()
|
||||||
|
}
|
||||||
|
wait(for: [expectation])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJobsLogOutputAlternativePlainText() throws {
|
||||||
|
let expected = "hello friends"
|
||||||
|
let context = try MockContext(content: expected)
|
||||||
|
|
||||||
|
let expectation = XCTestExpectation()
|
||||||
|
|
||||||
|
context.client.send(Job.Resources.LogOutput.Alternative(organization: "buildkite", pipeline: "my-pipeline", build: 1, job: UUID(), format: .plainText)) { result in
|
||||||
|
do {
|
||||||
|
let response = try result.get()
|
||||||
|
XCTAssertEqual(expected, response.content)
|
||||||
|
} catch {
|
||||||
|
XCTFail(error.localizedDescription)
|
||||||
|
}
|
||||||
|
expectation.fulfill()
|
||||||
|
}
|
||||||
|
wait(for: [expectation])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJobsLogOutputAlternativeHTML() throws {
|
||||||
|
let expected = "hello friends"
|
||||||
|
let context = try MockContext(content: expected)
|
||||||
|
|
||||||
|
let expectation = XCTestExpectation()
|
||||||
|
|
||||||
|
context.client.send(Job.Resources.LogOutput.Alternative(organization: "buildkite", pipeline: "my-pipeline", build: 1, job: UUID(), format: .html)) { result in
|
||||||
|
do {
|
||||||
|
let response = try result.get()
|
||||||
|
XCTAssertEqual(expected, response.content)
|
||||||
} catch {
|
} catch {
|
||||||
XCTFail(error.localizedDescription)
|
XCTFail(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
@ -104,3 +142,12 @@ class JobsTests: XCTestCase {
|
||||||
wait(for: [expectation])
|
wait(for: [expectation])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Job.LogOutput {
|
||||||
|
init() {
|
||||||
|
self.init(url: URL(),
|
||||||
|
content: "hello friends",
|
||||||
|
size: 13,
|
||||||
|
headerTimes: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -14,6 +14,13 @@ import FoundationNetworking
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
struct MockResources {
|
struct MockResources {
|
||||||
|
struct NoContentNoBody: Resource {
|
||||||
|
typealias Content = Void
|
||||||
|
let path = "mock"
|
||||||
|
}
|
||||||
|
|
||||||
|
var noContentNoBodyResource = NoContentNoBody()
|
||||||
|
|
||||||
struct HasContent: Resource, HasResponseBody {
|
struct HasContent: Resource, HasResponseBody {
|
||||||
struct Content: Codable, Equatable {
|
struct Content: Codable, Equatable {
|
||||||
var name: String
|
var name: String
|
||||||
|
@ -21,16 +28,53 @@ struct MockResources {
|
||||||
}
|
}
|
||||||
let path = "mock"
|
let path = "mock"
|
||||||
}
|
}
|
||||||
|
|
||||||
var contentResource = HasContent()
|
var contentResource = HasContent()
|
||||||
var content = HasContent.Content(name: "Jeff", age: 35)
|
var content = HasContent.Content(name: "Jeff", age: 35)
|
||||||
|
|
||||||
struct NoContent: Resource {
|
struct HasPaginatedContent: Resource, Paginated {
|
||||||
typealias Content = Void
|
struct Content: Codable, Equatable {
|
||||||
|
var name: String
|
||||||
|
var age: Int
|
||||||
|
}
|
||||||
|
|
||||||
let path = "mock"
|
let path = "mock"
|
||||||
}
|
}
|
||||||
|
|
||||||
var noContentResource = NoContent()
|
var paginatedContentResource = HasPaginatedContent()
|
||||||
|
var paginatedContent = HasPaginatedContent.Content(name: "Jeff", age: 35)
|
||||||
|
|
||||||
|
struct HasBody: Resource, HasRequestBody {
|
||||||
|
struct Body: Codable, Equatable {
|
||||||
|
var name: String
|
||||||
|
var age: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: Body
|
||||||
|
|
||||||
|
let path = "mock"
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyResource = HasBody(body: HasBody.Body(name: "Jeff", age: 35))
|
||||||
|
|
||||||
|
struct HasBodyAndPaginated: Resource, HasRequestBody, Paginated {
|
||||||
|
struct Body: Codable, Equatable {
|
||||||
|
var name: String
|
||||||
|
var age: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Content: Codable, Equatable {
|
||||||
|
var name: String
|
||||||
|
var age: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: Body
|
||||||
|
|
||||||
|
let path = "mock"
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyAndPaginatedResource = HasBodyAndPaginated(body: HasBodyAndPaginated.Body(name: "Jeff", age: 35))
|
||||||
|
var bodyAndPaginatedContent = HasBodyAndPaginated.Content(name: "Jeff", age: 35)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MockData {
|
enum MockData {
|
||||||
|
@ -57,7 +101,7 @@ extension MockData {
|
||||||
}
|
}
|
||||||
|
|
||||||
static func mockingUnsuccessfulResponse(for url: URL) -> (Data, URLResponse) {
|
static func mockingUnsuccessfulResponse(for url: URL) -> (Data, URLResponse) {
|
||||||
return (Data(), urlResponse(for: url, status: .notFound))
|
return (#"{"message":"not found","errors": ["go away"]}"#.data(using: .utf8)!, urlResponse(for: url, status: .notFound))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func mockingSuccessNoContent(for request: URLRequest) throws -> (Data, URLResponse) {
|
static func mockingSuccessNoContent(for request: URLRequest) throws -> (Data, URLResponse) {
|
||||||
|
@ -76,7 +120,7 @@ extension MockData {
|
||||||
HTTPURLResponse(url: url,
|
HTTPURLResponse(url: url,
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
httpVersion: "HTTP/1.1",
|
httpVersion: "HTTP/1.1",
|
||||||
headerFields: [:])!
|
headerFields: ["Link":#"<https://api.buildkite.com/v2/organizations/my-great-org/pipelines/my-pipeline/builds?api_key=f8582f070276d764ce3dd4c6d57be92574dccf86&page=3>; rel="prev",<https://api.buildkite.com/v2/organizations/my-great-org/pipelines/my-pipeline/builds?api_key=f8582f070276d764ce3dd4c6d57be92574dccf86&page=5>; rel="next",<https://api.buildkite.com/v2/organizations/my-great-org/pipelines/my-pipeline/builds?api_key=f8582f070276d764ce3dd4c6d57be92574dccf86&page=1>; rel="first", <https://api.buildkite.com/v2/organizations/my-great-org/pipelines/my-pipeline/builds?api_key=f8582f070276d764ce3dd4c6d57be92574dccf86&page=10>; rel="last""#])!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue