Basic pagination support

This commit is contained in:
Aaron Sky 2020-05-05 16:52:20 -04:00
parent fa81cfdf2c
commit 1b2c7472fa
19 changed files with 432 additions and 206 deletions

View File

@ -34,20 +34,6 @@
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BuildkiteIntegrationTests"
BuildableName = "BuildkiteIntegrationTests"
BlueprintName = "BuildkiteIntegrationTests"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
@ -67,16 +53,6 @@
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BuildkiteIntegrationTests"
BuildableName = "BuildkiteIntegrationTests"
BlueprintName = "BuildkiteIntegrationTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction

View File

@ -12,7 +12,7 @@ import Buildkite
let client = Buildkite()
client.token = "..."
var cancellables: Set<AnyCancellable> = []
client.sendPublisher(Build.Resources.ListAll())
client.sendPublisher(Build.Resources.Get(organization: "wayfair", pipeline: "merge-train-ci", build: 162))
.map(\.content)
.sink(receiveCompletion: { result in
if case let .failure(error) = result {

View File

@ -32,7 +32,7 @@ public struct Artifact: Codable, Equatable {
public var fileSize: Int
public var sha1sum: String
public struct URLs: Codable {
public struct URLs: Codable, Equatable {
public var url: URL
}
}

View File

@ -12,29 +12,72 @@ import Foundation
import FoundationNetworking
#endif
public struct Job: Codable, Equatable {
public struct Agent: Codable, Equatable {
public var id: UUID
public var url: URL
public var name: String
public enum Job: Codable, Equatable {
case script(Command)
case waiter(Wait)
case manual(Block)
case trigger(Trigger)
private enum Unassociated: String, Codable {
case script
case waiter
case manual
case trigger
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(Unassociated.self, forKey: .type)
switch type {
case .script:
self = .script(try Command(from: decoder))
case .waiter:
self = .waiter(try Wait(from: decoder))
case .manual:
self = .manual(try Block(from: decoder))
case .trigger:
self = .trigger(try Trigger(from: decoder))
}
}
public func encode(to encoder: Encoder) throws {
switch self {
case .script(let step):
try step.encode(to: encoder)
case .waiter(let step):
try step.encode(to: encoder)
case .manual(let step):
try step.encode(to: encoder)
case .trigger(let step):
try step.encode(to: encoder)
}
}
private enum CodingKeys: String, CodingKey {
case type
}
public struct Command: Codable, Equatable {
public struct AgentRef: Codable, Equatable {
public var id: UUID
public var type: String
public var name: String?
public var stepKey: String?
public var agentQueryRules: [String]
public var state: String?
public var buildUrl: URL?
public var webUrl: URL?
public var logUrl: URL?
public var rawLogUrl: URL?
public var artifactsUrl: URL?
public var name: String
public var url: URL
}
public let type = "script"
public var id: UUID
public var name: String
public var command: String?
public var stepKey: String?
public var buildUrl: URL
public var webUrl: URL
public var logUrl: URL
public var rawLogUrl: URL
public var artifactsUrl: URL
public var softFailed: Bool
public var exitStatus: Int?
public var exitStatus: Int
public var artifactPaths: String?
public var agent: Agent?
public var agentQueryRules: [String]
public var agent: AgentRef?
public var createdAt: Date
public var scheduledAt: Date
public var runnableAt: Date?
@ -45,10 +88,16 @@ public struct Job: Codable, Equatable {
public var retriesCount: Int?
public var parallelGroupIndex: Int?
public var parallelGroupTotal: Int?
}
public struct Unblocked: Codable, Equatable {
public struct Wait: Codable, Equatable {
public let type = "waiter"
public var id: UUID
}
public struct Block: Codable, Equatable {
public let type = "manual"
public var id: UUID
public var type: String
public var label: String
public var state: String
public var webUrl: URL?
@ -58,6 +107,26 @@ public struct Job: Codable, Equatable {
public var unblockUrl: URL
}
public struct Trigger: Codable, Equatable {
public let type = "trigger"
public var name: String
public var state: String
public var buildUrl: URL
public var webUrl: URL
public var createdAt: Date
public var scheduledAt: Date
public var finishedAt: Date?
public var runnableAt: Date?
public var triggeredBuild: TriggeredBuild
public struct TriggeredBuild: Codable, Equatable {
public var id: UUID
public var number: Int
public var url: URL
public var webUrl: URL
}
}
public struct LogOutput: Codable {
public var url: URL
public var content: String

View File

@ -159,7 +159,6 @@ extension Pipeline {
public var triggerBranch: String?
public var triggerAsync: Bool?
}
}
}

View File

@ -1,5 +1,5 @@
//
// Constants.swift
// Formatters.swift
//
//
// Created by Aaron Sky on 3/23/20.

View File

@ -0,0 +1,77 @@
//
// Pagination.swift
//
//
// Created by Aaron Sky on 5/5/20.
//
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
public struct Page {
var nextPage: Int?
var previousPage: Int?
var firstPage: Int?
var lastPage: Int?
init?(for header: String) {
guard !header.isEmpty else {
return nil
}
for link in header.split(separator: ",") {
let segments = link
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: ";")
guard
segments.count <= 2,
let urlString = segments.first,
urlString.hasPrefix("<") && urlString.hasSuffix(">"),
let url = URLComponents(string: String(urlString.dropFirst().dropLast())),
let pageString = url.queryItems?.first(where: { $0.name == "page" })?.value,
let page = Int(pageString) else {
continue
}
for segment in segments.dropFirst() {
switch segment.trimmingCharacters(in: .whitespacesAndNewlines) {
case "rel=\"next\"":
nextPage = page
case "rel=\"prev\"":
previousPage = page
case "rel=\"first\"":
firstPage = page
case "rel=\"last\"":
lastPage = page
default:
continue
}
}
}
}
}
public struct PageOptions {
public var page: Int
public var perPage: Int
public init(page: Int, perPage: Int) {
self.page = page
self.perPage = perPage
}
}
extension Array where Element == URLQueryItem {
init(options: PageOptions) {
self.init()
append(URLQueryItem(name: "page", value: String(options.page)))
append(URLQueryItem(name: "per_page", value: String(options.perPage)))
}
}

View File

@ -32,11 +32,6 @@ extension Resource {
}
extension URLRequest {
init<R: Resource & HasRequestBody>(_ resource: R, configuration: Configuration, encoder: JSONEncoder) throws {
self.init(resource, configuration: configuration)
httpBody = try encoder.encode(resource.body)
}
init<R: Resource>(_ resource: R, configuration: Configuration) {
let url = configuration.url(for: resource.path)
var request = URLRequest(url: url)
@ -44,4 +39,9 @@ extension URLRequest {
resource.transformRequest(&request)
self = request
}
init<R: Resource & HasRequestBody>(_ resource: R, configuration: Configuration, encoder: JSONEncoder) throws {
self.init(resource, configuration: configuration)
httpBody = try encoder.encode(resource.body)
}
}

View File

@ -19,9 +19,15 @@ enum ResponseError: Error {
public struct Response<T> {
public let content: T
public let response: URLResponse
public let page: Page?
init(content: T, response: URLResponse) {
self.content = content
self.response = response
if let response = response as? HTTPURLResponse, let link = response.allHeaderFields["Link"] as? String {
page = Page(for: link)
} else {
page = nil
}
}
}

View File

@ -24,6 +24,8 @@ extension Agent.Resources {
/// organization slug
public var organization: String
public var pageOptions: PageOptions?
public var path: String {
"organizations/\(organization)/agents"
}
@ -31,6 +33,17 @@ extension Agent.Resources {
public init(organization: String) {
self.organization = organization
}
public func transformRequest(_ request: inout URLRequest) {
guard let url = request.url,
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
if let options = pageOptions {
components.queryItems = [URLQueryItem](options: options)
}
request.url = components.url
}
}
public struct Get: Resource, HasResponseBody {

View File

@ -28,6 +28,8 @@ extension Annotation.Resources {
/// build number
public var build: Int
public var pageOptions: PageOptions?
public var path: String {
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/annotations"
}
@ -37,5 +39,16 @@ extension Annotation.Resources {
self.pipeline = pipeline
self.build = build
}
public func transformRequest(_ request: inout URLRequest) {
guard let url = request.url,
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
if let options = pageOptions {
components.queryItems = [URLQueryItem](options: options)
}
request.url = components.url
}
}
}

View File

@ -12,23 +12,25 @@ import FoundationNetworking
#endif
extension Artifact {
enum Resources { }
public enum Resources { }
}
extension Artifact.Resources {
/// List artifacts for a build
///
/// Returns a paginated list of a builds artifacts across all of its jobs.
struct ListByBuild: Resource, HasResponseBody {
typealias Content = [Artifact]
public struct ListByBuild: Resource, HasResponseBody {
public typealias Content = [Artifact]
/// organization slug
var organization: String
public var organization: String
/// pipeline slug
var pipeline: String
public var pipeline: String
/// build number
var build: Int
public var build: Int
var path: String {
public var pageOptions: PageOptions?
public var path: String {
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/artifacts"
}
@ -37,23 +39,36 @@ extension Artifact.Resources {
self.pipeline = pipeline
self.build = build
}
public func transformRequest(_ request: inout URLRequest) {
guard let url = request.url,
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
if let options = pageOptions {
components.queryItems = [URLQueryItem](options: options)
}
request.url = components.url
}
}
/// List artifacts for a job
///
/// Returns a paginated list of a jobs artifacts.
struct ListByJob: Resource, HasResponseBody {
typealias Content = [Artifact]
public struct ListByJob: Resource, HasResponseBody {
public typealias Content = [Artifact]
/// organization slug
var organization: String
public var organization: String
/// pipeline slug
var pipeline: String
public var pipeline: String
/// build number
var build: Int
public var build: Int
/// job ID
var jobId: UUID
public var jobId: UUID
var path: String {
public var pageOptions: PageOptions?
public var path: String {
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(jobId)/artifacts"
}
@ -63,23 +78,34 @@ extension Artifact.Resources {
self.build = build
self.jobId = jobId
}
public func transformRequest(_ request: inout URLRequest) {
guard let url = request.url,
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
if let options = pageOptions {
components.queryItems = [URLQueryItem](options: options)
}
request.url = components.url
}
}
/// Get an artifact
struct Get: Resource, HasResponseBody {
typealias Content = Artifact
public struct Get: Resource, HasResponseBody {
public typealias Content = Artifact
/// organization slug
var organization: String
public var organization: String
/// pipeline slug
var pipeline: String
public var pipeline: String
/// build number
var build: Int
public var build: Int
/// job ID
var jobId: UUID
public var jobId: UUID
/// artifact ID
var artifactId: UUID
public var artifactId: UUID
var path: String {
public var path: String {
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(jobId)/artifacts/\(artifactId)"
}
@ -95,20 +121,20 @@ extension Artifact.Resources {
/// Download an artifact
///
///
struct Download: Resource {
typealias Content = Artifact.URLs
public struct Download: Resource, HasResponseBody {
public typealias Content = Artifact.URLs
/// organization slug
var organization: String
public var organization: String
/// pipeline slug
var pipeline: String
public var pipeline: String
/// build number
var build: Int
public var build: Int
/// job ID
var jobId: UUID
public var jobId: UUID
/// artifact ID
var artifactId: UUID
public var artifactId: UUID
var path: String {
public var path: String {
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(jobId)/artifacts/\(artifactId)/download"
}
@ -124,27 +150,23 @@ extension Artifact.Resources {
/// Delete an artifact
///
///
struct Delete: Resource {
typealias Content = Void
public struct Delete: Resource {
public typealias Content = Void
/// organization slug
var organization: String
public var organization: String
/// pipeline slug
var pipeline: String
public var pipeline: String
/// build number
var build: Int
public var build: Int
/// job ID
var jobId: UUID
public var jobId: UUID
/// artifact ID
var artifactId: UUID
public var artifactId: UUID
var path: String {
public var path: String {
"organizations/\(organization)/pipelines/\(pipeline)/builds/\(build)/jobs/\(jobId)/artifacts/\(artifactId)"
}
func transformRequest(_ request: inout URLRequest) {
request.httpMethod = "DELETE"
}
public init(organization: String, pipeline: String, build: Int, jobId: UUID, artifactId: UUID) {
self.organization = organization
self.pipeline = pipeline
@ -152,5 +174,9 @@ extension Artifact.Resources {
self.jobId = jobId
self.artifactId = artifactId
}
public func transformRequest(_ request: inout URLRequest) {
request.httpMethod = "DELETE"
}
}
}

View File

@ -24,10 +24,13 @@ extension Build.Resources {
public typealias Content = [Build]
public let path = "builds"
public var options: QueryOptions?
public var queryOptions: QueryOptions?
public init(options: Build.Resources.QueryOptions? = nil) {
self.options = options
public var pageOptions: PageOptions?
public init(queryOptions: Build.Resources.QueryOptions? = nil, pageOptions: PageOptions? = nil) {
self.queryOptions = queryOptions
self.pageOptions = pageOptions
}
public func transformRequest(_ request: inout URLRequest) {
@ -35,9 +38,14 @@ extension Build.Resources {
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
if let options = options {
components.queryItems = [URLQueryItem](options: options)
var queryItems: [URLQueryItem] = []
if let options = queryOptions {
queryItems.append(contentsOf: [URLQueryItem](options: options))
}
if let options = pageOptions {
queryItems.append(contentsOf: [URLQueryItem](options: options))
}
components.queryItems = queryItems
request.url = components.url
}
}
@ -51,15 +59,18 @@ extension Build.Resources {
/// organization slug
public var organization: String
public var options: QueryOptions?
public var queryOptions: QueryOptions?
public var pageOptions: PageOptions?
public var path: String {
"organizations/\(organization)/builds"
}
public init(organization: String, options: Build.Resources.QueryOptions? = nil) {
public init(organization: String, queryOptions: Build.Resources.QueryOptions? = nil, pageOptions: PageOptions? = nil) {
self.organization = organization
self.options = options
self.queryOptions = queryOptions
self.pageOptions = pageOptions
}
public func transformRequest(_ request: inout URLRequest) {
@ -67,9 +78,14 @@ extension Build.Resources {
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
if let options = options {
components.queryItems = [URLQueryItem](options: options)
var queryItems: [URLQueryItem] = []
if let options = queryOptions {
queryItems.append(contentsOf: [URLQueryItem](options: options))
}
if let options = pageOptions {
queryItems.append(contentsOf: [URLQueryItem](options: options))
}
components.queryItems = queryItems
request.url = components.url
}
}
@ -84,16 +100,19 @@ extension Build.Resources {
/// pipeline slug
public var pipeline: String
public var options: QueryOptions?
public var queryOptions: QueryOptions?
public var pageOptions: PageOptions?
public var path: String {
"organizations/\(organization)/pipelines/\(pipeline)/builds"
}
public init(organization: String, pipeline: String, options: Build.Resources.QueryOptions? = nil) {
public init(organization: String, pipeline: String, queryOptions: Build.Resources.QueryOptions? = nil, pageOptions: PageOptions? = nil) {
self.organization = organization
self.pipeline = pipeline
self.options = options
self.queryOptions = queryOptions
self.pageOptions = pageOptions
}
public func transformRequest(_ request: inout URLRequest) {
@ -101,9 +120,14 @@ extension Build.Resources {
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
if let options = options {
components.queryItems = [URLQueryItem](options: options)
var queryItems: [URLQueryItem] = []
if let options = queryOptions {
queryItems.append(contentsOf: [URLQueryItem](options: options))
}
if let options = pageOptions {
queryItems.append(contentsOf: [URLQueryItem](options: options))
}
components.queryItems = queryItems
request.url = components.url
}
}
@ -260,18 +284,6 @@ extension Build.Resources {
}
public struct QueryOptions {
internal init(branches: [String] = [], commit: String? = nil, createdFrom: Date? = nil, createdTo: Date? = nil, creator: UUID? = nil, finishedFrom: Date? = nil, includeRetriedJobs: Bool? = nil, metadata: [String : String] = [:], state: String? = nil) {
self.branches = branches
self.commit = commit
self.createdFrom = createdFrom
self.createdTo = createdTo
self.creator = creator
self.finishedFrom = finishedFrom
self.includeRetriedJobs = includeRetriedJobs
self.metadata = metadata
self.state = state
}
/// Filters the results by the given branch or branches.
public var branches: [String] = []
/// Filters the results by the commit (only works for full sha, not for shortened ones).
@ -290,6 +302,18 @@ extension Build.Resources {
public var metadata: [String: String] = [:]
/// Filters the results by the given build state. The finished state is a shortcut to automatically search for builds with passed, failed, blocked, canceled states.
public var state: String?
public init(branches: [String] = [], commit: String? = nil, createdFrom: Date? = nil, createdTo: Date? = nil, creator: UUID? = nil, finishedFrom: Date? = nil, includeRetriedJobs: Bool? = nil, metadata: [String : String] = [:], state: String? = nil) {
self.branches = branches
self.commit = commit
self.createdFrom = createdFrom
self.createdTo = createdTo
self.creator = creator
self.finishedFrom = finishedFrom
self.includeRetriedJobs = includeRetriedJobs
self.metadata = metadata
self.state = state
}
}
}

View File

@ -23,7 +23,22 @@ extension Organization.Resources {
public typealias Content = [Organization]
public let path = "organizations"
public init() {}
public var pageOptions: PageOptions?
public init(pageOptions: PageOptions? = nil) {
self.pageOptions = pageOptions
}
public func transformRequest(_ request: inout URLRequest) {
guard let url = request.url,
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
if let options = pageOptions {
components.queryItems = [URLQueryItem](options: options)
}
request.url = components.url
}
}
/// Get an organization

View File

@ -24,12 +24,26 @@ extension Pipeline.Resources {
/// organization slug
public var organization: String
public var pageOptions: PageOptions?
public var path: String {
"organizations/\(organization)/pipelines"
}
public init(organization: String) {
public init(organization: String, pageOptions: PageOptions? = nil) {
self.organization = organization
self.pageOptions = pageOptions
}
public func transformRequest(_ request: inout URLRequest) {
guard let url = request.url,
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
if let options = pageOptions {
components.queryItems = [URLQueryItem](options: options)
}
request.url = components.url
}
}

View File

@ -26,7 +26,30 @@ extension Agent {
version: "3.20.0",
creator: User(),
createdAt: Date(timeIntervalSince1970: 1000),
job: Job(),
job: Job.script(Job.Command(id: UUID(),
name: "📦",
command: nil,
stepKey: nil,
buildUrl: URL(),
webUrl: URL(),
logUrl: URL(),
rawLogUrl: URL(),
artifactsUrl: URL(),
softFailed: false,
exitStatus: 0,
artifactPaths: nil,
agentQueryRules: [],
agent: nil,
createdAt: Date(timeIntervalSince1970: 1000),
scheduledAt: Date(timeIntervalSince1970: 1000),
runnableAt: nil,
startedAt: nil,
finishedAt: nil,
retried: false,
retriedInJobId: nil,
retriesCount: nil,
parallelGroupIndex: nil,
parallelGroupTotal: nil) ),
lastJobFinishedAt: nil,
priority: nil,
metaData: [])

View File

@ -85,13 +85,15 @@ class ArtifactsTests: XCTestCase {
}
func testArtifactsDownload() throws {
let context = MockContext()
let expected = Artifact.URLs(url: URL())
let context = try MockContext(content: expected)
let expectation = XCTestExpectation()
context.client.send(Artifact.Resources.Download(organization: "buildkite", pipeline: "my-pipeline", build: 1, jobId: UUID(), artifactId: UUID())) { result in
do {
_ = try result.get()
let response = try result.get()
XCTAssertEqual(expected, response.content)
} catch {
XCTFail(error.localizedDescription)
}

View File

@ -61,7 +61,7 @@ class BuildsTests: XCTestCase {
let expected = [Build(), Build()]
let context = try MockContext(content: expected)
let resource = Build.Resources.ListAll(options: Build.Resources.QueryOptions(branches: ["master"],
let resource = Build.Resources.ListAll(queryOptions: Build.Resources.QueryOptions(branches: ["master"],
commit: "HEAD",
createdFrom: Date(timeIntervalSince1970: 1000),
createdTo: Date(timeIntervalSince1970: 1000),

View File

@ -13,40 +13,9 @@ import XCTest
import FoundationNetworking
#endif
extension Job {
init() {
self.init(id: UUID(),
type: "script",
name: "📦",
stepKey: "package",
agentQueryRules: [],
state: "finished",
buildUrl: URL(),
webUrl: URL(),
logUrl: URL(),
rawLogUrl: URL(),
artifactsUrl: URL(),
command: "echo 1",
softFailed: false,
exitStatus: 0,
artifactPaths: "",
agent: nil,
createdAt: Date(timeIntervalSince1970: 1000),
scheduledAt: Date(timeIntervalSince1970: 1000),
runnableAt: nil,
startedAt: nil,
finishedAt: nil,
retried: false,
retriedInJobId: UUID(),
retriesCount: 0,
parallelGroupIndex: nil,
parallelGroupTotal: nil)
}
}
class JobsTests: XCTestCase {
func testJobsRetry() throws {
let expected = Job()
let expected = Job.waiter(Job.Wait(id: UUID()))
let context = try MockContext(content: expected)
let expectation = XCTestExpectation()
@ -64,7 +33,7 @@ class JobsTests: XCTestCase {
}
func testJobsUnblock() throws {
let expected = Job()
let expected = Job.waiter(Job.Wait(id: UUID()))
let context = try MockContext(content: expected)
let body = Job.Resources.Unblock.Body()