diff --git a/.gitignore b/.gitignore index 5cd0309..9e5a9f8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /*.xcodeproj xcuserdata/ Package.resolved +*.env* \ No newline at end of file diff --git a/Examples/Package.swift b/Examples/Package.swift index 921db19..c3bb466 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -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" ), diff --git a/Examples/test-analytics/Example.swift b/Examples/test-analytics/Example.swift index 51359b9..b329b6e 100644 --- a/Examples/test-analytics/Example.swift +++ b/Examples/test-analytics/Example.swift @@ -27,6 +27,6 @@ import Foundation ) .content - print(result.runUrl) + print(result.runURL) } } diff --git a/Examples/webhooks/BuildkiteClient+Middleware.swift b/Examples/webhooks/BuildkiteClient+Middleware.swift new file mode 100644 index 0000000..709d88e --- /dev/null +++ b/Examples/webhooks/BuildkiteClient+Middleware.swift @@ -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(_ 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 { + 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) + ) + } + } +} diff --git a/Examples/webhooks/Example.swift b/Examples/webhooks/Example.swift index 5d535d0..f71f3c7 100644 --- a/Examples/webhooks/Example.swift +++ b/Examples/webhooks/Example.swift @@ -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() } } diff --git a/Package.swift b/Package.swift index 784ed8c..e1e205e 100644 --- a/Package.swift +++ b/Package.swift @@ -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", diff --git a/Sources/Buildkite/BuildkiteClient.swift b/Sources/Buildkite/BuildkiteClient.swift index 93c4bd1..c59e269 100644 --- a/Sources/Buildkite/BuildkiteClient.swift +++ b/Sources/Buildkite/BuildkiteClient.swift @@ -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 } diff --git a/Sources/Buildkite/Models/Agent.swift b/Sources/Buildkite/Models/Agent.swift index 79b7fe2..c4c1c1d 100644 --- a/Sources/Buildkite/Models/Agent.swift +++ b/Sources/Buildkite/Models/Agent.swift @@ -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 - 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" + } } diff --git a/Sources/Buildkite/Models/Annotation.swift b/Sources/Buildkite/Models/Annotation.swift index fe84b3d..d2de2b6 100644 --- a/Sources/Buildkite/Models/Annotation.swift +++ b/Sources/Buildkite/Models/Annotation.swift @@ -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" + } } diff --git a/Sources/Buildkite/Models/Artifact.swift b/Sources/Buildkite/Models/Artifact.swift index b78f399..5628147 100644 --- a/Sources/Buildkite/Models/Artifact.swift +++ b/Sources/Buildkite/Models/Artifact.swift @@ -23,7 +23,7 @@ public struct Artifact: Codable, Equatable, Hashable, Identifiable, Sendable { public var id: UUID public var jobId: UUID public var url: Followable - public var downloadUrl: Followable + public var downloadURL: Followable 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 + } } diff --git a/Sources/Buildkite/Models/Build.swift b/Sources/Buildkite/Models/Build.swift index 6b91aeb..4345276 100644 --- a/Sources/Buildkite/Models/Build.swift +++ b/Sources/Buildkite/Models/Build.swift @@ -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 - 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 + } } diff --git a/Sources/Buildkite/Models/Job.swift b/Sources/Buildkite/Models/Job.swift index ce3e78a..a8c21ab 100644 --- a/Sources/Buildkite/Models/Job.swift +++ b/Sources/Buildkite/Models/Job.swift @@ -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 - public var rawLogUrl: Followable - public var artifactsUrl: URL + public var buildURL: URL + public var webURL: URL + public var logURL: Followable + public var rawLogURL: Followable + 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 { diff --git a/Sources/Buildkite/Models/Meta.swift b/Sources/Buildkite/Models/Meta.swift index aba98a0..a017d4e 100644 --- a/Sources/Buildkite/Models/Meta.swift +++ b/Sources/Buildkite/Models/Meta.swift @@ -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" } } diff --git a/Sources/Buildkite/Models/Organization.swift b/Sources/Buildkite/Models/Organization.swift index e8e1a2b..c886dfd 100644 --- a/Sources/Buildkite/Models/Organization.swift +++ b/Sources/Buildkite/Models/Organization.swift @@ -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 - public var webUrl: URL + public var webURL: URL public var name: String public var slug: String - public var pipelinesUrl: Followable - public var agentsUrl: Followable - public var emojisUrl: Followable + public var pipelinesURL: Followable + public var agentsURL: Followable + public var emojisURL: Followable 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" + } } diff --git a/Sources/Buildkite/Models/Pipeline.swift b/Sources/Buildkite/Models/Pipeline.swift index 40159f9..d96a4cc 100644 --- a/Sources/Buildkite/Models/Pipeline.swift +++ b/Sources/Buildkite/Models/Pipeline.swift @@ -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 - 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 - public var badgeUrl: URL + public var buildsURL: Followable + 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" + } } } } diff --git a/Sources/Buildkite/Models/Team.swift b/Sources/Buildkite/Models/Team.swift index c44488a..5df0d28 100644 --- a/Sources/Buildkite/Models/Team.swift +++ b/Sources/Buildkite/Models/Team.swift @@ -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" + } } diff --git a/Sources/Buildkite/Models/Trace.swift b/Sources/Buildkite/Models/Trace.swift index e49e2c6..b7b3924 100644 --- a/Sources/Buildkite/Models/Trace.swift +++ b/Sources/Buildkite/Models/Trace.swift @@ -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 } } diff --git a/Sources/Buildkite/Models/User.swift b/Sources/Buildkite/Models/User.swift index 2b8bcde..0bfafca 100644 --- a/Sources/Buildkite/Models/User.swift +++ b/Sources/Buildkite/Models/User.swift @@ -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" + } } diff --git a/Sources/Buildkite/Models/WebhookEvent.swift b/Sources/Buildkite/Models/WebhookEvent.swift index f01beff..2aed703 100644 --- a/Sources/Buildkite/Models/WebhookEvent.swift +++ b/Sources/Buildkite/Models/WebhookEvent.swift @@ -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" } } } diff --git a/Sources/Buildkite/Networking/BuildkiteClient+Webhooks.swift b/Sources/Buildkite/Networking/BuildkiteClient+Webhooks.swift new file mode 100644 index 0000000..50dfc8c --- /dev/null +++ b/Sources/Buildkite/Networking/BuildkiteClient+Webhooks.swift @@ -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.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 + } + } +} diff --git a/Sources/Buildkite/Networking/Formatters.swift b/Sources/Buildkite/Networking/Formatters.swift index d28968b..d257b98 100644 --- a/Sources/Buildkite/Networking/Formatters.swift +++ b/Sources/Buildkite/Networking/Formatters.swift @@ -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 + } +} diff --git a/Sources/Buildkite/Networking/URL+Buildkite.swift b/Sources/Buildkite/Networking/URL+Buildkite.swift index 5f67e7e..ed27dd3 100644 --- a/Sources/Buildkite/Networking/URL+Buildkite.swift +++ b/Sources/Buildkite/Networking/URL+Buildkite.swift @@ -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 -// } -//} diff --git a/Sources/Buildkite/Resources/REST/Followable.swift b/Sources/Buildkite/Resources/REST/Followable.swift index 9ba7366..afdc3e6 100644 --- a/Sources/Buildkite/Resources/REST/Followable.swift +++ b/Sources/Buildkite/Resources/REST/Followable.swift @@ -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(...) /// ``` public struct Followable: Resource, Codable, Equatable, Hashable, Sendable { diff --git a/Sources/Buildkite/Resources/TestAnalytics/TestAnalyticsUpload.swift b/Sources/Buildkite/Resources/TestAnalytics/TestAnalyticsUpload.swift index df3a183..1dd54a2 100644 --- a/Sources/Buildkite/Resources/TestAnalytics/TestAnalyticsUpload.swift +++ b/Sources/Buildkite/Resources/TestAnalytics/TestAnalyticsUpload.swift @@ -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 { diff --git a/Tests/BuildkiteTests/Networking/FormattersTests.swift b/Tests/BuildkiteTests/Networking/FormattersTests.swift new file mode 100644 index 0000000..75cff33 --- /dev/null +++ b/Tests/BuildkiteTests/Networking/FormattersTests.swift @@ -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() { + + } +} diff --git a/Tests/BuildkiteTests/Resources/REST/AgentsTests.swift b/Tests/BuildkiteTests/Resources/REST/AgentsTests.swift index 2a101de..01591f3 100644 --- a/Tests/BuildkiteTests/Resources/REST/AgentsTests.swift +++ b/Tests/BuildkiteTests/Resources/REST/AgentsTests.swift @@ -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", diff --git a/Tests/BuildkiteTests/Resources/REST/ArtifactsTests.swift b/Tests/BuildkiteTests/Resources/REST/ArtifactsTests.swift index 1aec292..f3edb7f 100644 --- a/Tests/BuildkiteTests/Resources/REST/ArtifactsTests.swift +++ b/Tests/BuildkiteTests/Resources/REST/ArtifactsTests.swift @@ -21,7 +21,7 @@ extension Artifact { id: UUID(), jobId: UUID(), url: Followable(), - downloadUrl: Followable(), + downloadURL: Followable(), state: .new, path: "", dirname: "", diff --git a/Tests/BuildkiteTests/Resources/REST/BuildsTests.swift b/Tests/BuildkiteTests/Resources/REST/BuildsTests.swift index 1fcc39e..a518c02 100644 --- a/Tests/BuildkiteTests/Resources/REST/BuildsTests.swift +++ b/Tests/BuildkiteTests/Resources/REST/BuildsTests.swift @@ -19,8 +19,9 @@ extension Build { init() { self.init( id: UUID(), + graphqlId: "", url: Followable(), - webUrl: URL(), + webURL: URL(), number: 1, state: .passed, blocked: false, diff --git a/Tests/BuildkiteTests/Resources/REST/JobsTests.swift b/Tests/BuildkiteTests/Resources/REST/JobsTests.swift index 7bf22a5..69ccfa5 100644 --- a/Tests/BuildkiteTests/Resources/REST/JobsTests.swift +++ b/Tests/BuildkiteTests/Resources/REST/JobsTests.swift @@ -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) diff --git a/Tests/BuildkiteTests/Resources/REST/OrganizationsTests.swift b/Tests/BuildkiteTests/Resources/REST/OrganizationsTests.swift index 8493ba1..f383a07 100644 --- a/Tests/BuildkiteTests/Resources/REST/OrganizationsTests.swift +++ b/Tests/BuildkiteTests/Resources/REST/OrganizationsTests.swift @@ -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) ) } diff --git a/Tests/BuildkiteTests/Resources/REST/PipelinesTests.swift b/Tests/BuildkiteTests/Resources/REST/PipelinesTests.swift index 35624b3..cebc9f5 100644 --- a/Tests/BuildkiteTests/Resources/REST/PipelinesTests.swift +++ b/Tests/BuildkiteTests/Resources/REST/PipelinesTests.swift @@ -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, diff --git a/Tests/BuildkiteTests/Resources/REST/TeamsTests.swift b/Tests/BuildkiteTests/Resources/REST/TeamsTests.swift index 0f53f83..baef8a4 100644 --- a/Tests/BuildkiteTests/Resources/REST/TeamsTests.swift +++ b/Tests/BuildkiteTests/Resources/REST/TeamsTests.swift @@ -19,6 +19,7 @@ extension Team { fileprivate init() { self.init( id: UUID(), + graphqlId: "", name: "", slug: "", description: "", diff --git a/Tests/BuildkiteTests/Resources/REST/UsersTests.swift b/Tests/BuildkiteTests/Resources/REST/UsersTests.swift index 784f578..596c6fe 100644 --- a/Tests/BuildkiteTests/Resources/REST/UsersTests.swift +++ b/Tests/BuildkiteTests/Resources/REST/UsersTests.swift @@ -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) ) } diff --git a/Tests/BuildkiteTests/Resources/TestAnalytics/TestAnalyticsUploadTests.swift b/Tests/BuildkiteTests/Resources/TestAnalytics/TestAnalyticsUploadTests.swift index f941a00..d7d81f2 100644 --- a/Tests/BuildkiteTests/Resources/TestAnalytics/TestAnalyticsUploadTests.swift +++ b/Tests/BuildkiteTests/Resources/TestAnalytics/TestAnalyticsUploadTests.swift @@ -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(