Skip to content

Commit 4482c9c

Browse files
authored
Merge pull request #1 from diegotl/feature/convenience-builder-api
Add convenience result builder APIs and fix critical bugs
2 parents 160d25a + 104c197 commit 4482c9c

17 files changed

+2324
-136
lines changed

README.md

Lines changed: 105 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ let client = try SlackWebhookClient.create(
6767
try await client.send(Message(text: "Hello, Slack!"))
6868
```
6969

70+
## Convenience Builder API
71+
72+
SlackKit includes a builder API for cleaner, more readable message construction:
73+
74+
```swift
75+
// Clean, declarative syntax
76+
let message = Message {
77+
Header("Deployment Complete!")
78+
Section("Build *#123* was deployed to *production*")
79+
Divider()
80+
SectionBlock(
81+
fields: [
82+
.markdown("*Environment:*\nProduction"),
83+
.markdown("*Version:*\nv2.4.1")
84+
]
85+
)
86+
}
87+
try await client.send(message)
88+
```
89+
7090
## Usage
7191

7292
### Simple Text Message
@@ -79,52 +99,45 @@ try await client.send(message)
7999
### Message with Blocks
80100

81101
```swift
82-
let message = Message(
83-
username: "DeployBot",
84-
iconEmoji: ":rocket:",
85-
blocks: [
86-
HeaderBlock(text: "Deployment Complete!"),
87-
88-
SectionBlock(
89-
text: .markdown("Build *#123* was deployed to *production*")
90-
),
102+
let message = Message {
103+
Header("Deployment Complete!")
104+
Section(markdown: "Build *#123* was deployed to *production*")
105+
Divider()
106+
107+
Section {
108+
Field.markdown("*Environment:*\nProduction")
109+
Field.markdown("*Version:*\nv2.4.1")
110+
Field.markdown("*Duration:*\n5m 32s")
111+
Field.markdown("*Status:*\n:white_check_mark: Success")
112+
}
113+
}
114+
try await client.send(message)
115+
```
91116

92-
DividerBlock(),
117+
**With custom username and icon:**
93118

94-
SectionBlock(
95-
fields: [
96-
.markdown("*Environment:*\nProduction"),
97-
.markdown("*Version:*\nv2.4.1"),
98-
.markdown("*Duration:*\n5m 32s"),
99-
.markdown("*Status:*\n:white_check_mark: Success")
100-
]
101-
)
102-
]
103-
)
119+
```swift
120+
let message = Message(
121+
username: "DeployBot",
122+
iconEmoji: ":rocket:"
123+
) {
124+
Header("Deployment Complete!")
125+
Section("Build *#123* was deployed to *production*")
126+
Divider()
127+
}
104128
try await client.send(message)
105129
```
106130

107131
### Message with Actions
108132

109133
```swift
110-
let message = Message(
111-
text: "Approval required for production deployment",
112-
blocks: [
113-
SectionBlock(text: .plainText("Deploy to production?")),
114-
ActionsBlock(elements: [
115-
ButtonElement(
116-
text: .plainText("Approve"),
117-
style: .primary,
118-
value: "approve"
119-
),
120-
ButtonElement(
121-
text: .plainText("Reject"),
122-
style: .danger,
123-
value: "reject"
124-
)
125-
])
126-
]
127-
)
134+
let message = Message(text: "Approval required for production deployment") {
135+
Section("Deploy to production?")
136+
Actions {
137+
ButtonElement(text: .plainText("Approve"), style: .primary, value: "approve")
138+
ButtonElement(text: .plainText("Reject"), style: .danger, value: "reject")
139+
}
140+
}
128141
try await client.send(message)
129142
```
130143

@@ -165,85 +178,93 @@ try await client.send(message)
165178
Text sections with optional fields:
166179

167180
```swift
168-
SectionBlock(
169-
text: .markdown("Some *formatted* text"),
170-
fields: [
171-
.markdown("*Field 1*\nValue 1"),
172-
.markdown("*Field 2*\nValue 2")
173-
]
174-
)
181+
Section("Some *formatted* text")
182+
// Or with markdown
183+
Section(markdown: "Some *formatted* text")
184+
```
185+
186+
With fields using the result builder:
187+
188+
```swift
189+
Section {
190+
Field.markdown("*Field 1*\nValue 1")
191+
Field.plainText("Field 2")
192+
}
175193
```
176194

177195
### Header Block
178196

179197
Large header text:
180198

181199
```swift
182-
HeaderBlock(text: "Important Announcement")
200+
Header("Important Announcement")
183201
```
184202

185203
### Divider Block
186204

187205
Horizontal line divider:
188206

189207
```swift
190-
DividerBlock()
208+
Divider()
191209
```
192210

193211
### Image Block
194212

195213
Display an image:
196214

197215
```swift
198-
ImageBlock(
199-
imageURL: URL(string: "https://example.com/image.png")!,
200-
altText: "An example image",
201-
title: .plainText("Image Title")
202-
)
216+
Image(url: "https://example.com/image.png", altText: "An example image")
203217
```
204218

205219
### Actions Block
206220

207221
Interactive buttons:
208222

209223
```swift
210-
ActionsBlock(elements: [
211-
ButtonElement(
212-
text: .plainText("Click Me"),
213-
actionID: "button_1",
214-
value: "button_value",
215-
style: .primary
216-
)
217-
])
224+
Actions {
225+
ButtonElement(text: .plainText("Click Me"), actionID: "button_1", value: "button_value", style: .primary)
226+
}
227+
```
228+
229+
The builder also supports conditionals and loops:
230+
231+
```swift
232+
Actions {
233+
ButtonElement(text: .plainText("Approve"), actionID: "approve", value: "yes")
234+
235+
if needsReview {
236+
ButtonElement(text: .plainText("Request Review"), actionID: "review", value: "review")
237+
}
238+
239+
for option in options {
240+
ButtonElement(text: .plainText(option), actionID: "opt_\(option)", value: option)
241+
}
242+
}
218243
```
219244

220245
### Context Block
221246

222-
Contextual information with images and text:
247+
Contextual information with text and images:
223248

224249
```swift
225-
ContextBlock(elements: [
226-
TextContextElement(text: "Created by @john"),
227-
ImageContextElement(
228-
imageURL: "https://example.com/avatar.png",
229-
altText: "Avatar"
230-
)
231-
])
250+
// Simple text context
251+
Context("Created by @john", "2 minutes ago")
252+
253+
// Or with elements using the builder
254+
Context {
255+
TextContextElement(text: "Created by @john")
256+
ImageContextElement(imageURL: "https://example.com/avatar.png", altText: "Avatar")
257+
}
232258
```
233259

234260
### Input Block (Modals)
235261

236262
Input blocks for collecting user input in modals:
237263

238264
```swift
239-
InputBlock(
240-
label: .plainText("Task description"),
241-
element: PlainTextInputElement(
242-
placeholder: "Enter task details...",
243-
multiline: true
244-
),
245-
hint: .plainText("Be specific about the requirements"),
246-
optional: false
265+
Input(
266+
label: "Task description",
267+
element: PlainTextInputElement(placeholder: "Enter task details...", multiline: true)
247268
)
248269
```
249270

@@ -263,26 +284,19 @@ ButtonElement(
263284
### Select Menu
264285

265286
```swift
266-
StaticSelectElement(
267-
placeholder: .plainText("Choose an option"),
268-
options: [
269-
Option(text: .plainText("Option 1"), value: "opt1"),
270-
Option(text: .plainText("Option 2"), value: "opt2")
271-
]
272-
)
287+
StaticSelectElement(placeholder: .plainText("Choose an option")) {
288+
Option(text: .plainText("Option 1"), value: "opt1")
289+
Option(text: .plainText("Option 2"), value: "opt2")
290+
}
273291
```
274292

275293
### Multi-Select Menu
276294

277295
```swift
278-
MultiStaticSelectElement(
279-
placeholder: .plainText("Select options"),
280-
options: [
281-
Option(text: .plainText("Option 1"), value: "opt1"),
282-
Option(text: .plainText("Option 2"), value: "opt2")
283-
],
284-
maxSelectedItems: 3
285-
)
296+
MultiStaticSelectElement(placeholder: .plainText("Select options"), maxSelectedItems: 3) {
297+
Option(text: .plainText("Option 1"), value: "opt1")
298+
Option(text: .plainText("Option 2"), value: "opt2")
299+
}
286300
```
287301

288302
### Date Picker

Sources/SlackKit/Client/SlackWebhookClient.swift

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,18 @@ public final actor SlackWebhookClient {
8686
// Send the request
8787
let response = try await networkClient.post(url: webhookURL, body: body)
8888

89+
// Check for rate limiting (must check before isSuccess)
90+
if response.statusCode == 429 {
91+
let retryAfter = extractRetryAfter(from: response) ?? 60
92+
throw SlackError.rateLimitExceeded(retryAfter: retryAfter)
93+
}
94+
8995
// Check for HTTP errors
9096
guard response.isSuccess else {
9197
let bodyString = String(data: response.data, encoding: .utf8)
9298
throw SlackError.invalidResponse(statusCode: response.statusCode, body: bodyString)
9399
}
94100

95-
// Check for rate limiting
96-
if response.statusCode == 429 {
97-
if let retryAfter = extractRetryAfter(from: response) {
98-
throw SlackError.rateLimitExceeded(retryAfter: retryAfter)
99-
}
100-
}
101-
102101
// Decode the response
103102
// Slack webhooks return "ok" as plain text on success
104103
if let bodyString = String(data: response.data, encoding: .utf8),

Sources/SlackKit/Client/URLSessionNetworkClient.swift

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,11 @@ import Foundation
88
/// A URLSession-based implementation of NetworkClient
99
public actor URLSessionNetworkClient: NetworkClient {
1010
private let session: URLSession
11-
private let decoder: JSONDecoder
1211

1312
/// Initializes a new URLSession network client
14-
/// - Parameters:
15-
/// - session: The URLSession to use for requests (defaults to shared)
16-
/// - decoder: The JSONDecoder to use for decoding responses (defaults to standard)
17-
public init(
18-
session: URLSession = .shared,
19-
decoder: JSONDecoder = JSONDecoder()
20-
) {
13+
/// - Parameter session: The URLSession to use for requests (defaults to shared)
14+
public init(session: URLSession = .shared) {
2115
self.session = session
22-
self.decoder = decoder
2316
}
2417

2518
public func post(url: URL, body: Data) async throws -> HTTPResponse {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import Foundation
2+
3+
// MARK: - ActionsBuilder
4+
5+
/// A result builder for constructing ActionsBlock elements
6+
@resultBuilder
7+
public enum ActionsBuilder {
8+
/// Builds an empty element array
9+
public static func buildBlock() -> [any BlockElement] {
10+
[]
11+
}
12+
13+
/// Builds an element array from multiple elements
14+
public static func buildBlock(_ components: [any BlockElement]...) -> [any BlockElement] {
15+
components.flatMap { $0 }
16+
}
17+
18+
/// Builds an element array from a single element expression
19+
public static func buildExpression(_ expression: any BlockElement) -> [any BlockElement] {
20+
[expression]
21+
}
22+
23+
/// Builds an element array from an optional element expression
24+
public static func buildExpression(_ expression: (any BlockElement)?) -> [any BlockElement] {
25+
expression.map { [$0] } ?? []
26+
}
27+
28+
/// Builds an element array from an array of elements (pass-through)
29+
public static func buildExpression(_ expression: [any BlockElement]) -> [any BlockElement] {
30+
expression
31+
}
32+
33+
/// Builds an element array from an if block
34+
public static func buildIf(_ content: [any BlockElement]?) -> [any BlockElement] {
35+
content ?? []
36+
}
37+
38+
/// Builds an element array from an if-else block (first branch)
39+
public static func buildEither(first component: [any BlockElement]) -> [any BlockElement] {
40+
component
41+
}
42+
43+
/// Builds an element array from an if-else block (second branch)
44+
public static func buildEither(second component: [any BlockElement]) -> [any BlockElement] {
45+
component
46+
}
47+
48+
/// Builds an element array from a for loop
49+
public static func buildArray(_ components: [[any BlockElement]]) -> [any BlockElement] {
50+
components.flatMap { $0 }
51+
}
52+
53+
/// Builds the final element array
54+
public static func buildFinalBlock(_ component: [any BlockElement]) -> [any BlockElement] {
55+
component
56+
}
57+
}
58+
59+
// MARK: - ActionsBlock Convenience Initializer
60+
61+
extension ActionsBlock {
62+
/// Initializes a new actions block using a result builder
63+
/// - Parameters:
64+
/// - blockID: An optional identifier for the block
65+
/// - builder: A result builder closure that provides the elements
66+
public init(
67+
blockID: String? = nil,
68+
@ActionsBuilder builder: () -> [any BlockElement]
69+
) {
70+
self.elements = builder()
71+
self.blockID = blockID
72+
}
73+
}

0 commit comments

Comments
 (0)