* Added graphql_id property

* renamed URL fields
* WIP webhooks support
* WIP webhooks example
This commit is contained in:
Aaron Sky 2022-09-10 20:09:04 -04:00
parent 2bd91f7315
commit 790f6e7453
34 changed files with 720 additions and 110 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@
/*.xcodeproj
xcuserdata/
Package.resolved
*.env*

View File

@ -17,7 +17,8 @@ let package = Package(
.executable(name: "webhooks", targets: ["webhooks"]),
],
dependencies: [
.package(name: "Buildkite", path: "../")
.package(name: "Buildkite", path: "../"),
.package(url: "https://github.com/vapor/vapor", .upToNextMajor(from: "4.0.0"))
],
targets: [
.executableTarget(
@ -51,7 +52,8 @@ let package = Package(
.executableTarget(
name: "webhooks",
dependencies: [
.product(name: "Buildkite", package: "Buildkite")
.product(name: "Buildkite", package: "Buildkite"),
.product(name: "Vapor", package: "vapor")
],
path: "webhooks"
),

View File

@ -27,6 +27,6 @@ import Foundation
)
.content
print(result.runUrl)
print(result.runURL)
}
}

View File

@ -0,0 +1,116 @@
//
// BuildkiteClient+Middleware.swift
// webhooks
//
// Created by Aaron Sky on 9/5/22.
// Copyright © 2022 Aaron Sky. All rights reserved.
//
import Buildkite
import Foundation
import Vapor
extension HTTPHeaders {
fileprivate var buildkiteEvent: String? {
self.first(name: WebhookEvent.HTTPHeaders.buildkiteEvent)
}
fileprivate var buildkiteToken: String? {
self.first(name: WebhookEvent.HTTPHeaders.buildkiteToken)
}
fileprivate var buildkiteSignature: String? {
self.first(name: WebhookEvent.HTTPHeaders.buildkiteSignature)
}
}
extension BuildkiteClient: ContentDecoder {
public nonisolated func decode<D>(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders) throws -> D where D : Decodable {
// FIXME: force cast
try decodeWebhook(from: Data(buffer: body)) as! D
}
}
@available(macOS 12.0, *)
extension BuildkiteClient: AsyncMiddleware {
public func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Vapor.Response {
guard Environment.get("BUILDKITE_NO_VERIFY") == nil else {
return try await next.respond(to: request)
}
guard
let secretKey = Environment
.get("BUILDKITE_WEBHOOK_SECRET")?
.data(using: .utf8)
else {
throw Abort(
.preconditionFailed,
reason: "Server not configured with webhook authentication token"
)
}
if
let signature = request.headers.buildkiteSignature,
let payload = request.body.data {
try self.validateWebhookPayload(
signatureHeader: signature,
body: Data(buffer: payload),
secretKey: secretKey
)
} else if let token = request.headers.buildkiteToken {
try self.validateWebhookPayload(tokenHeader: token, secretKey: secretKey)
} else {
throw Abort(.unauthorized)
}
return try await next.respond(to: request)
}
}
extension BuildkiteClient: Middleware {
public nonisolated func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture<Vapor.Response> {
guard Environment.get("BUILDKITE_NO_VERIFY") == nil else {
return next.respond(to: request)
}
guard
let secretKey = Environment
.get("BUILDKITE_WEBHOOK_SECRET")?
.data(using: .utf8)
else {
return request.eventLoop.future(
error: Abort(
.preconditionFailed,
reason: "Server not configured with webhook authentication token"
)
)
}
if
let signature = request.headers.buildkiteSignature,
let payload = request.body.data {
return request.eventLoop.tryFuture {
try self.validateWebhookPayload(
signatureHeader: signature,
body: Data(buffer: payload),
secretKey: secretKey
)
}.flatMap {
next.respond(to: request)
}
} else if let token = request.headers.buildkiteToken {
return request.eventLoop.tryFuture {
try self.validateWebhookPayload(
tokenHeader: token,
secretKey: secretKey
)
}.flatMap {
next.respond(to: request)
}
} else {
return request.eventLoop.future(
error: Abort(.unauthorized)
)
}
}
}

View File

@ -7,14 +7,28 @@
//
import Buildkite
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import Vapor
@available(macOS 12.0, *)
@main struct Example {
static func main() {
static func main() throws {
let app = try Application(.detect())
defer { app.shutdown() }
let buildkite = BuildkiteClient(/*transport: app.client*/)
app.group(buildkite) {
$0.post("buildkite_webhook") { req in
let event = try req.content.decode(WebhookEvent.self, using: buildkite)
print(event)
return HTTPStatus.ok
}
}
app.get("buildkite_webhook") { req in
"Hello world"
}
try app.run()
}
}

View File

@ -16,10 +16,18 @@ let package = Package(
targets: ["Buildkite"]
)
],
dependencies: [
.package(
url: "https://github.com/apple/swift-crypto.git",
.upToNextMajor(from: "2.0.0")
)
],
targets: [
.target(
name: "Buildkite",
dependencies: []
dependencies: [
.product(name: "Crypto", package: "swift-crypto"),
]
),
.testTarget(
name: "BuildkiteTests",

View File

@ -13,17 +13,15 @@ import FoundationNetworking
#endif
public actor BuildkiteClient {
private var encoder: JSONEncoder {
nonisolated var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .custom(Formatters.encodeISO8601)
encoder.keyEncodingStrategy = .convertToSnakeCase
return encoder
}
private var decoder: JSONDecoder {
nonisolated var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom(Formatters.decodeISO8601)
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}

View File

@ -14,8 +14,9 @@ import FoundationNetworking
public struct Agent: Codable, Equatable, Hashable, Identifiable, Sendable {
public var id: UUID
public var graphqlId: String
public var url: Followable<Agent.Resources.Get>
public var webUrl: URL
public var webURL: URL
public var name: String
public var connectionState: String
public var hostname: String
@ -28,4 +29,23 @@ public struct Agent: Codable, Equatable, Hashable, Identifiable, Sendable {
public var lastJobFinishedAt: Date?
public var priority: Int?
public var metaData: [String]
private enum CodingKeys: String, CodingKey {
case id
case graphqlId = "graphql_id"
case url
case webURL = "web_url"
case name
case connectionState = "connection_state"
case hostname
case ipAddress = "ip_address"
case userAgent = "user_agent"
case version
case creator
case createdAt = "created_at"
case job
case lastJobFinishedAt = "last_job_finished_at"
case priority
case metaData = "meta_data"
}
}

View File

@ -26,4 +26,13 @@ public struct Annotation: Codable, Equatable, Hashable, Identifiable, Sendable {
public var bodyHtml: String
public var createdAt: Date
public var updatedAt: Date
private enum CodingKeys: String, CodingKey {
case id
case context
case style
case bodyHtml = "body_html"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}

View File

@ -23,7 +23,7 @@ public struct Artifact: Codable, Equatable, Hashable, Identifiable, Sendable {
public var id: UUID
public var jobId: UUID
public var url: Followable<Artifact.Resources.Get>
public var downloadUrl: Followable<Artifact.Resources.Download>
public var downloadURL: Followable<Artifact.Resources.Download>
public var state: State
public var path: String
public var dirname: String
@ -35,4 +35,18 @@ public struct Artifact: Codable, Equatable, Hashable, Identifiable, Sendable {
public struct URLs: Codable, Equatable {
public var url: URL
}
private enum CodingKeys: String, CodingKey {
case id
case jobId = "job_id"
case url
case downloadURL = "download_url"
case state
case path
case dirname
case filename
case mimeType = "mime_type"
case fileSize = "file_size"
case sha1sum
}
}

View File

@ -14,8 +14,9 @@ import FoundationNetworking
public struct Build: Codable, Equatable, Hashable, Identifiable, Sendable {
public var id: UUID
public var graphqlId: String
public var url: Followable<Build.Resources.Get>
public var webUrl: URL
public var webURL: URL
public var number: Int
public var state: State
public var blocked: Bool
@ -46,4 +47,28 @@ public struct Build: Codable, Equatable, Hashable, Identifiable, Sendable {
case notRun = "not_run"
case finished
}
private enum CodingKeys: String, CodingKey {
case id
case graphqlId = "graphql_id"
case url
case webURL = "web_url"
case number
case state
case blocked
case message
case commit
case branch
case env
case source
case creator
case jobs
case createdAt = "created_at"
case scheduledAt = "scheduled_at"
case startedAt = "started_at"
case finishedAt = "finished_at"
case metaData = "meta_data"
case pullRequest = "pull_request"
case pipeline
}
}

View File

@ -68,15 +68,16 @@ public enum Job: Codable, Equatable, Hashable, Sendable {
public var type = "script"
public var id: UUID
public var graphqlId: String
public var name: String?
public var state: String?
public var command: String?
public var stepKey: String?
public var buildUrl: URL
public var webUrl: URL
public var logUrl: Followable<Job.Resources.LogOutput>
public var rawLogUrl: Followable<Job.Resources.LogOutput.Alternative>
public var artifactsUrl: URL
public var buildURL: URL
public var webURL: URL
public var logURL: Followable<Job.Resources.LogOutput>
public var rawLogURL: Followable<Job.Resources.LogOutput.Alternative>
public var artifactsURL: URL
public var softFailed: Bool
public var exitStatus: Int?
public var artifactPaths: String?
@ -92,23 +93,74 @@ public enum Job: Codable, Equatable, Hashable, Sendable {
public var retriesCount: Int?
public var parallelGroupIndex: Int?
public var parallelGroupTotal: Int?
private enum CodingKeys: String, CodingKey {
case type
case id
case graphqlId = "graphql_id"
case name
case state
case command
case stepKey = "step_key"
case buildURL = "build_url"
case webURL = "web_url"
case logURL = "log_url"
case rawLogURL = "raw_log_url"
case artifactsURL = "artifacts_url"
case softFailed = "soft_failed"
case exitStatus = "exit_status"
case artifactPaths = "artifact_paths"
case agentQueryRules = "agent_query_rules"
case agent
case createdAt = "created_at"
case scheduledAt = "scheduled_at"
case runnableAt = "runnable_at"
case startedAt = "started_at"
case finishedAt = "finished_at"
case retried
case retriedInJobId = "retried_in_job_id"
case retriesCount = "retries_count"
case parallelGroupIndex = "parallel_group_index"
case parallelGroupTotal = "parallel_group_total"
}
}
public struct Wait: Codable, Equatable, Hashable, Identifiable, Sendable {
public var type = "waiter"
public var id: UUID
public var graphqlId: String
private enum CodingKeys: String, CodingKey {
case type
case id
case graphqlId = "graphql_id"
}
}
public struct Block: Codable, Equatable, Hashable, Identifiable, Sendable {
public var type = "manual"
public var id: UUID
public var graphqlId: String
public var label: String
public var state: String
public var webUrl: URL?
public var webURL: URL?
public var unblockedBy: User?
public var unblockedAt: Date?
public var unblockable: Bool
public var unblockUrl: URL
public var unblockURL: URL
private enum CodingKeys: String, CodingKey {
case type
case id
case graphqlId = "graphql_id"
case label
case state
case webURL = "web_url"
case unblockedBy = "unblocked_by"
case unblockedAt = "unblocked_at"
case unblockable
case unblockURL = "unblock_url"
}
}
public struct Trigger: Codable, Equatable, Hashable, Sendable {
@ -116,19 +168,39 @@ public enum Job: Codable, Equatable, Hashable, Sendable {
public var id: UUID
public var number: Int
public var url: URL
public var webUrl: URL
public var webURL: URL
private enum CodingKeys: String, CodingKey {
case id
case number
case url
case webURL = "web_url"
}
}
public var type = "trigger"
public var name: String?
public var state: String?
public var buildUrl: URL
public var webUrl: URL
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?
private enum CodingKeys: String, CodingKey {
case type
case name
case state
case buildURL = "build_url"
case webURL = "web_url"
case createdAt = "created_at"
case scheduledAt = "scheduled_at"
case finishedAt = "finished_at"
case runnableAt = "runnable_at"
case triggeredBuild = "triggered_build"
}
}
public struct LogOutput: Codable, Equatable, Hashable, Sendable {
@ -136,6 +208,13 @@ public enum Job: Codable, Equatable, Hashable, Sendable {
public var content: String
public var size: Int
public var headerTimes: [Int]
private enum CodingKeys: String, CodingKey {
case url
case content
case size
case headerTimes = "header_times"
}
}
public struct EnvironmentVariables: Codable, Equatable, Hashable, Sendable {

View File

@ -23,7 +23,6 @@ public struct Meta: Codable, Equatable, Hashable, Sendable {
public var webhookIPRanges: [String]
private enum CodingKeys: String, CodingKey {
// This corresponds to the key "webhook_ips" from the Buildkite payload.
case webhookIPRanges = "webhookIps"
case webhookIPRanges = "webhook_ips"
}
}

View File

@ -14,12 +14,26 @@ import FoundationNetworking
public struct Organization: Codable, Equatable, Hashable, Identifiable, Sendable {
public var id: UUID
public var graphqlId: String
public var url: Followable<Organization.Resources.Get>
public var webUrl: URL
public var webURL: URL
public var name: String
public var slug: String
public var pipelinesUrl: Followable<Pipeline.Resources.List>
public var agentsUrl: Followable<Agent.Resources.List>
public var emojisUrl: Followable<Emoji.Resources.List>
public var pipelinesURL: Followable<Pipeline.Resources.List>
public var agentsURL: Followable<Agent.Resources.List>
public var emojisURL: Followable<Emoji.Resources.List>
public var createdAt: Date
private enum CodingKeys: String, CodingKey {
case id
case graphqlId = "graphql_id"
case url
case webURL = "web_url"
case name
case slug
case pipelinesURL = "pipelines_url"
case agentsURL = "agents_url"
case emojisURL = "emojis_url"
case createdAt = "created_at"
}
}

View File

@ -14,8 +14,9 @@ import FoundationNetworking
public struct Pipeline: Codable, Equatable, Hashable, Identifiable, Sendable {
public var id: UUID
public var graphqlId: String
public var url: Followable<Pipeline.Resources.Get>
public var webUrl: URL
public var webURL: URL
public var name: String
public var slug: String
public var repository: String
@ -26,8 +27,8 @@ public struct Pipeline: Codable, Equatable, Hashable, Identifiable, Sendable {
public var skipQueuedBranchBuildsFilter: String?
public var cancelRunningBranchBuilds: Bool
public var cancelRunningBranchBuildsFilter: String?
public var buildsUrl: Followable<Build.Resources.ListForPipeline>
public var badgeUrl: URL
public var buildsURL: Followable<Build.Resources.ListForPipeline>
public var badgeURL: URL
public var createdAt: Date
public var scheduledBuildsCount: Int
public var runningBuildsCount: Int
@ -40,8 +41,42 @@ public struct Pipeline: Codable, Equatable, Hashable, Identifiable, Sendable {
public struct Provider: Codable, Equatable, Hashable, Identifiable, Sendable {
public var id: String
public var webhookUrl: URL?
public var webhookURL: URL?
public var settings: Settings
private enum CodingKeys: String, CodingKey {
case id
case webhookURL = "webhook_url"
case settings
}
}
private enum CodingKeys: String, CodingKey {
case id
case graphqlId = "graphql_id"
case url
case webURL = "web_url"
case name
case slug
case repository
case branchConfiguration = "branch_configuration"
case defaultBranch = "default_branch"
case provider
case skipQueuedBranchBuilds = "skip_queued_branch_builds"
case skipQueuedBranchBuildsFilter = "skip_queued_branch_builds_filter"
case cancelRunningBranchBuilds = "cancel_running_branch_builds"
case cancelRunningBranchBuildsFilter = "cancel_running_branch_builds_filter"
case buildsURL = "builds_url"
case badgeURL = "badge_url"
case createdAt = "created_at"
case scheduledBuildsCount = "scheduled_builds_count"
case runningBuildsCount = "running_builds_count"
case scheduledJobsCount = "scheduled_jobs_count"
case runningJobsCount = "running_jobs_count"
case waitingJobsCount = "waiting_jobs_count"
case visibility
case steps
case env
}
}
@ -76,6 +111,24 @@ extension Pipeline.Provider {
public var separatePullRequestStatuses: Bool?
/// The status to use for blocked builds. Pending can be used with required status checks to prevent merging pull requests with blocked builds.
public var publishBlockedAsPending: Bool?
private enum CodingKeys: String, CodingKey {
case repository
case buildPullRequests = "build_pull_requests"
case pullRequestBranchFilterEnabled = "pull_request_branch_filter_enabled"
case pullRequestBranchFilterConfiguration = "pull_request_branch_filter_configuration"
case skipPullRequestBuildsForExistingCommits = "skip_pull_request_builds_for_existing_commits"
case buildTags = "build_tags"
case publishCommitStatus = "publish_commit_status"
case publishCommitStatusPerStep = "publish_commit_status_per_step"
case triggerMode = "trigger_mode"
case filterEnabled = "filter_enabled"
case filterCondition = "filter_condition"
case buildPullRequestForks = "build_pull_request_forks"
case prefixPullRequestForkBranchNames = "prefix_pull_request_fork_branch_names"
case separatePullRequestStatuses = "separate_pull_request_statuses"
case publishBlockedAsPending = "publish_blocked_as_pending"
}
}
}
@ -140,12 +193,33 @@ extension Pipeline {
public var async: Bool?
public var concurrency: Int?
public var parallelism: Int?
private enum CodingKeys: String, CodingKey {
case type
case name
case command
case label
case artifactPaths = "artifact_paths"
case branchConfiguration = "branch_configuration"
case env
case timeoutInMinutes = "timeout_in_minutes"
case agentQueryRules = "agent_query_rules"
case async
case concurrency
case parallelism
}
}
public struct Wait: Codable, Equatable, Hashable, Sendable {
public var type = "waiter"
public var label: String?
public var continueAfterFailure: Bool?
private enum CodingKeys: String, CodingKey {
case type
case label
case continueAfterFailure = "continue_after_failure"
}
}
public struct Block: Codable, Equatable, Hashable, Sendable {
@ -160,6 +234,15 @@ extension Pipeline {
public var triggerCommit: String?
public var triggerBranch: String?
public var triggerAsync: Bool?
private enum CodingKeys: String, CodingKey {
case type
case triggerProjectSlug = "trigger_project_slug"
case label
case triggerCommit = "trigger_commit"
case triggerBranch = "trigger_branch"
case triggerAsync = "trigger_async"
}
}
}
}

View File

@ -15,6 +15,7 @@ import FoundationNetworking
public struct Team: Codable, Equatable, Hashable, Identifiable, Sendable {
/// ID of the team
public var id: UUID
public var graphqlId: String
/// Name of the team
public var name: String
/// URL slug of the team
@ -34,4 +35,16 @@ public struct Team: Codable, Equatable, Hashable, Identifiable, Sendable {
case visible
case secret
}
private enum CodingKeys: String, CodingKey {
case id
case graphqlId = "graphql_id"
case name
case slug
case description
case privacy
case `default`
case createdAt = "created_at"
case createdBy = "created_by"
}
}

View File

@ -96,5 +96,26 @@ public struct Trace: Codable, Equatable, Hashable, Identifiable, Sendable {
self.detail = detail
self.children = children
}
private enum CodingKeys: String, CodingKey {
case section
case startAt = "start_at"
case endAt = "end_at"
case duration
case detail
case children
}
}
private enum CodingKeys: String, CodingKey {
case id
case scope
case name
case identifier
case location
case fileName = "file_name"
case result
case failureReason = "failure_reason"
case history
}
}

View File

@ -14,8 +14,18 @@ import FoundationNetworking
public struct User: Codable, Equatable, Hashable, Identifiable, Sendable {
public var id: UUID
public var graphqlId: String
public var name: String
public var email: String
public var avatarUrl: URL
public var avatarURL: URL
public var createdAt: Date
private enum CodingKeys: String, CodingKey {
case id
case graphqlId = "graphql_id"
case name
case email
case avatarURL = "avatar_url"
case createdAt = "created_at"
}
}

View File

@ -84,12 +84,17 @@ public enum WebhookEvent: Codable, Equatable, Hashable, Sendable {
}
public struct Ping: Codable, Equatable, Hashable, Sendable {
public var event: Event
/// The notification service that sent this webhook
public var service: Service
/// The ``Organization`` this notification belongs to
public var organization: Organization
/// The user who created the webhook
public var sender: Sender
public enum Event: String, Codable, Equatable, Hashable, Sendable {
case ping
}
}
public struct Build: Codable, Equatable, Hashable, Sendable {
@ -103,11 +108,11 @@ public enum WebhookEvent: Codable, Equatable, Hashable, Sendable {
public enum Event: String, Codable, Equatable, Hashable, Sendable {
/// A build has been scheduled
case scheduled
case scheduled = "build.scheduled"
/// A build has started running
case running
case running = "build.running"
/// A build has finished
case finished
case finished = "build.finished"
}
}
@ -124,13 +129,13 @@ public enum WebhookEvent: Codable, Equatable, Hashable, Sendable {
public enum Event: String, Codable, Equatable, Hashable, Sendable {
/// A command step job has been scheduled to run on an agent
case scheduled
case scheduled = "job.scheduled"
/// A command step job has started running on an agent
case started
case started = "job.started"
/// A job has finished
case finished
case finished = "job.finished"
/// A block step job has been unblocked using the web or API
case activated
case activated = "job.activated"
}
}
@ -143,15 +148,15 @@ public enum WebhookEvent: Codable, Equatable, Hashable, Sendable {
public enum Event: String, Codable, Equatable, Hashable, Sendable {
/// An agent has connected to the API
case connected
case connected = "agent.connected"
/// An agent has been marked as lost. This happens when Buildkite stops receiving pings from the agent
case lost
case lost = "agent.lost"
/// An agent has disconnected. This happens when the agent shuts down and disconnects from the API
case disconnected
case disconnected = "agent.disconnected"
/// An agent is stopping. This happens when an agent is instructed to stop from the API. It first transitions to stopping and finishes any current jobs
case stopping
case stopping = "agent.stopping"
/// An agent has stopped. This happens when an agent is instructed to stop from the API. It can be graceful or forceful
case stopped
case stopped = "agent.stopped"
}
}
}

View File

@ -0,0 +1,145 @@
//
// BuildkiteClient+Webhooks.swift
// Buildkite
//
// Created by Aaron Sky on 9/10/22.
// Copyright © 2022 Aaron Sky. All rights reserved.
//
import Crypto
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
extension WebhookEvent {
/// Header names present in every Buildkite webhook request.
///
/// - SeeAlso: https://buildkite.com/docs/apis/webhooks#http-headers
public enum HTTPHeaders {
/// The type of event
public static let buildkiteEvent = "X-Buildkite-Event"
/// The webhook's token.
public static let buildkiteToken = "X-Buildkite-Token"
/// The signature created from your webhook payload, webhook token, and the SHA-256 hash function.
public static let buildkiteSignature = "X-Buildkite-Signature"
}
}
extension BuildkiteClient {
public enum WebhookValidationError: Error {
// Token
case tokenRefused
// Signature
case signatureFormatInvalid
case signatureCorrupted
case payloadCorrupted
case signatureRefused
// Replay Protection
case timestampRefused
}
public nonisolated func decodeWebhook(from body: Data) throws -> WebhookEvent {
try decoder.decode(WebhookEvent.self, from: body)
}
/// Validate a webhook payload using the token strategy.
///
/// - Warning: Buildkite passes the token in clear text. As a consequence, this strategy is less secure than the Buildkite Signature strategy, but easier for rapid testing and less computationally expensive.
///
/// - Parameters:
/// - tokenHeader: Value for the header with the name at ``WebhookEvent/HTTPHeaders/buildkiteToken``
/// - secretKey: Token value provided by Buildkite
public nonisolated func validateWebhookPayload(
tokenHeader: String,
secretKey: Data
) throws {
guard secretKey == tokenHeader.data(using: .utf8) else {
throw WebhookValidationError.tokenRefused
}
}
/// Validate the webhook event using the signature strategy.
///
/// - Parameters:
/// - signatureHeader: Value for the header with the name at ``WebhookEvent/HTTPHeaders/buildkiteSignature``
/// - body: The request body from Buildkite
/// - secretKey: Token value provided by Buildkite
/// - replayLimit: Limit in seconds the period of time a webhook event will be accepted. This is used to defend against replay attacks, but is optional.
public nonisolated func validateWebhookPayload(
signatureHeader: String,
body: Data,
secretKey: Data,
replayLimit: TimeInterval? = nil
) throws {
let (timestamp, signature) = try getTimestampAndSignature(signatureHeader)
guard let macPayload = "\(timestamp).\(body)".data(using: .utf8) else {
throw WebhookValidationError.payloadCorrupted
}
try checkMAC(
message: macPayload,
signature: signature,
secretKey: secretKey
)
if let replayLimit = replayLimit {
try checkReplayLimit(time: timestamp, replayLimit: replayLimit)
}
}
private nonisolated func getTimestampAndSignature(_ header: String) throws -> (timestamp: Date, signature: Data) {
let parts: [String: String] = Dictionary(
uniqueKeysWithValues: header
.split(separator: ",")
.map { kv in
let kvp = kv.split(separator: "=", maxSplits: 2)
.map { $0.trimmingCharacters(in: .whitespaces) }
return (kvp[0], kvp[1])
}
)
guard parts.count == 2 else {
throw WebhookValidationError.signatureFormatInvalid
}
guard
let timestamp = parts["timestamp"]
.flatMap(TimeInterval.init)
.flatMap(Date.init(timeIntervalSince1970:)),
// FIXME: Needs to be a hex-decoded
let signature = parts["signature"]?
.data(using: .utf8)
else {
throw WebhookValidationError.signatureCorrupted
}
return (timestamp, signature)
}
private nonisolated func checkMAC(message: Data, signature: Data, secretKey: Data) throws {
let key = SymmetricKey(data: secretKey)
let expectedMAC = HMAC<SHA256>.authenticationCode(
for: message,
using: key
)
guard
HMAC.isValidAuthenticationCode(
expectedMAC,
authenticating: signature,
using: key
)
else {
throw WebhookValidationError.signatureRefused
}
}
private nonisolated func checkReplayLimit(
time: Date,
replayLimit: TimeInterval
) throws {
guard time.timeIntervalSinceNow <= replayLimit else {
throw WebhookValidationError.timestampRefused
}
}
}

View File

@ -25,19 +25,10 @@ enum Formatters {
return formatter
}()
static func dateIfPossible(fromISO8601 string: String) -> Date? {
if let date = iso8601WithFractionalSeconds.date(from: string) {
return date
} else if let date = iso8601WithoutFractionalSeconds.date(from: string) {
return date
}
return nil
}
@Sendable
static func encodeISO8601(date: Date, encoder: Encoder) throws {
var container = encoder.singleValueContainer()
let dateString = iso8601WithoutFractionalSeconds.string(from: date)
let dateString = iso8601WithFractionalSeconds.string(from: date)
try container.encode(dateString)
}
@ -45,14 +36,24 @@ enum Formatters {
static func decodeISO8601(decoder: Decoder) throws -> Date {
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
guard let date = dateIfPossible(fromISO8601: dateString) else {
guard let date = Date(iso8601String: dateString) else {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: container.codingPath,
debugDescription: "Expected date string to be ISO8601-formatted."
debugDescription: "Expected date string \"\(dateString)\" to be ISO8601-formatted."
)
)
}
return date
}
}
extension Date {
init?(iso8601String: String) {
guard
let date = Formatters.iso8601WithFractionalSeconds.date(from: iso8601String) ??
Formatters.iso8601WithoutFractionalSeconds.date(from: iso8601String)
else { return nil }
self = date
}
}

View File

@ -76,25 +76,3 @@ extension Array where Element == URLQueryItem {
)
}
}
//extension Date: LosslessStringConvertible {
// public init?(
// _ description: String
// ) {
// guard let date = Formatters.dateIfPossible(fromISO8601: description) else {
// return nil
// }
// self = date
// }
//}
//
//extension UUID: LosslessStringConvertible {
// public init?(
// _ description: String
// ) {
// guard let id = UUID(uuidString: description) else {
// return nil
// }
// self = id
// }
//}

View File

@ -20,7 +20,7 @@ import FoundationNetworking
/// ```swift
/// let client = BuildkiteClient(token: "...")
/// let organizationResponse = await client.send(.organization("buildkite")
/// let agentsResponse = await client.send(organizationResponse.content.agentsUrl)
/// let agentsResponse = await client.send(organizationResponse.content.agentsURL)
/// print(agentsResponse.content) // Array<Agent>(...)
/// ```
public struct Followable<R: Resource>: Resource, Codable, Equatable, Hashable, Sendable {

View File

@ -69,7 +69,16 @@ extension TestAnalytics.Resources {
public var queued: Int
public var skipped: Int
public var errors: [String]
public var runUrl: URL
public var runURL: URL
private enum CodingKeys: String, CodingKey {
case id
case runId = "run_id"
case queued
case skipped
case errors
case runURL = "run_url"
}
}
public var version: APIVersion {

View File

@ -0,0 +1,38 @@
//
// FormattersTests.swift
// Buildkite
//
// Copyright © 2022 Aaron Sky. All rights reserved.
//
import Foundation
import XCTest
@testable import Buildkite
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
final class FormattersTests: XCTestCase {
func testDecode() throws {
// let expected = DateComponents(
// year: 2020,
// month: 3,
// day: 14,
// hour: 20,
// minute: 4,
// second: 43
// ).date!
// let actual = try XCTUnwrap(
// Formatters.dateIfPossible(
// fromISO8601: "2020-03-14T20:04:43.567Z"
// )
// )
// XCTAssertEqual(actual, expected)
}
func testEncode() {
}
}

View File

@ -20,15 +20,16 @@ extension Agent {
let job = Job.script(
Job.Command(
id: UUID(),
graphqlId: "",
name: "📦",
state: "passed",
command: nil,
stepKey: nil,
buildUrl: URL(),
webUrl: URL(),
logUrl: Followable(),
rawLogUrl: Followable(),
artifactsUrl: URL(),
buildURL: URL(),
webURL: URL(),
logURL: Followable(),
rawLogURL: Followable(),
artifactsURL: URL(),
softFailed: false,
exitStatus: 0,
artifactPaths: nil,
@ -49,8 +50,9 @@ extension Agent {
self.init(
id: UUID(),
graphqlId: "",
url: Followable(),
webUrl: URL(),
webURL: URL(),
name: "jeffrey",
connectionState: "connected",
hostname: "jeffrey",

View File

@ -21,7 +21,7 @@ extension Artifact {
id: UUID(),
jobId: UUID(),
url: Followable(),
downloadUrl: Followable(),
downloadURL: Followable(),
state: .new,
path: "",
dirname: "",

View File

@ -19,8 +19,9 @@ extension Build {
init() {
self.init(
id: UUID(),
graphqlId: "",
url: Followable(),
webUrl: URL(),
webURL: URL(),
number: 1,
state: .passed,
blocked: false,

View File

@ -17,7 +17,7 @@ import FoundationNetworking
class JobsTests: XCTestCase {
func testJobsRetryWaiter() async throws {
let expected: Job = .waiter(Job.Wait(id: UUID()))
let expected: Job = .waiter(Job.Wait(id: UUID(), graphqlId: ""))
let context = try MockContext(content: expected)
let response = try await context.client.send(
@ -32,8 +32,8 @@ class JobsTests: XCTestCase {
Job.Trigger(
name: nil,
state: nil,
buildUrl: URL(),
webUrl: URL(),
buildURL: URL(),
webURL: URL(),
createdAt: Date(timeIntervalSince1970: 1000),
scheduledAt: nil,
finishedAt: nil,
@ -42,7 +42,7 @@ class JobsTests: XCTestCase {
id: UUID(),
number: 0,
url: URL(),
webUrl: URL()
webURL: URL()
)
)
)
@ -59,13 +59,14 @@ class JobsTests: XCTestCase {
let expected: Job = .manual(
Job.Block(
id: UUID(),
graphqlId: "",
label: "",
state: "",
webUrl: nil,
webURL: nil,
unblockedBy: User(),
unblockedAt: Date(timeIntervalSince1970: 1000),
unblockable: true,
unblockUrl: URL()
unblockURL: URL()
)
)
let context = try MockContext(content: expected)

View File

@ -19,13 +19,14 @@ extension Organization {
init() {
self.init(
id: UUID(),
graphqlId: "",
url: Followable(),
webUrl: URL(),
webURL: URL(),
name: "Buildkite",
slug: "buildkite",
pipelinesUrl: Followable(),
agentsUrl: Followable(),
emojisUrl: Followable(),
pipelinesURL: Followable(),
agentsURL: Followable(),
emojisURL: Followable(),
createdAt: Date(timeIntervalSince1970: 1000)
)
}

View File

@ -21,8 +21,9 @@ extension Pipeline {
) {
self.init(
id: UUID(),
graphqlId: "",
url: Followable(),
webUrl: URL(),
webURL: URL(),
name: "My Pipeline",
slug: "my-pipeline",
repository: "git@github.com:buildkite/agent.git",
@ -30,7 +31,7 @@ extension Pipeline {
defaultBranch: "master",
provider: Provider(
id: "github",
webhookUrl: URL(),
webhookURL: URL(),
settings: Provider.Settings(
repository: nil,
buildPullRequests: nil,
@ -53,8 +54,8 @@ extension Pipeline {
skipQueuedBranchBuildsFilter: nil,
cancelRunningBranchBuilds: false,
cancelRunningBranchBuildsFilter: nil,
buildsUrl: Followable(),
badgeUrl: URL(),
buildsURL: Followable(),
badgeURL: URL(),
createdAt: Date(timeIntervalSince1970: 1000),
scheduledBuildsCount: 0,
runningBuildsCount: 0,

View File

@ -19,6 +19,7 @@ extension Team {
fileprivate init() {
self.init(
id: UUID(),
graphqlId: "",
name: "",
slug: "",
description: "",

View File

@ -19,9 +19,10 @@ extension User {
init() {
self.init(
id: UUID(),
graphqlId: "",
name: "Jeff",
email: "jeff@buildkite.com",
avatarUrl: URL(),
avatarURL: URL(),
createdAt: Date(timeIntervalSince1970: 1000)
)
}

View File

@ -24,7 +24,7 @@ class TestAnalyticsUpload: XCTestCase {
.init(id: .init(), history: .init(section: "http")),
.init(id: .init(), history: .init(section: "http")),
]
let expected = Resource.Content(id: .init(), runId: .init(), queued: 0, skipped: 0, errors: [], runUrl: .init())
let expected = Resource.Content(id: .init(), runId: .init(), queued: 0, skipped: 0, errors: [], runURL: .init())
let context = try MockContext(content: expected)
let response = try await context.client.send(