Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ A Swift client for the [Figma REST API](https://www.figma.com/developers/api) wi

## Features

- 8 Figma API endpoints (files, components, nodes, images, styles, variables)
- Full Figma REST API coverage (46 endpoints) — files, components, styles, variables, comments, webhooks, dev resources, analytics, and more
- Token-bucket rate limiting with fair round-robin scheduling
- Exponential backoff retry with jitter and `Retry-After` support
- Figma Variables API (read + write codeSyntax)
- Figma Variables API (read local, read published, write codeSyntax)
- Support for both API v1 and v2 (webhooks)
- GitHub Releases endpoint (for version checking)
- Swift 6 strict concurrency

Expand Down Expand Up @@ -47,12 +48,18 @@ Then add `FigmaAPI` to your target dependencies:
```swift
import FigmaAPI

// Create a client with your Figma personal access token
// Create a client
let figma = FigmaClient(accessToken: "your-figma-token", timeout: nil)

// Wrap with rate limiting and retry
let rateLimiter = SharedRateLimiter()
let client = RateLimitedClient(
inner: FigmaClient(accessToken: "your-figma-token")
client: figma,
rateLimiter: rateLimiter,
configID: "default"
)

// Fetch components
// Fetch components from a file
let components = try await client.request(
ComponentsEndpoint(fileId: "your-file-id")
)
Expand All @@ -64,8 +71,22 @@ let variables = try await client.request(

// Export images as SVG
let images = try await client.request(
ImageEndpoint(fileId: "your-file-id", nodeIds: ["1:2", "3:4"], format: .svg)
ImageEndpoint(fileId: "your-file-id", nodeIds: ["1:2", "3:4"], params: SVGParams())
)

// Get current user
let me = try await client.request(GetMeEndpoint())

// Post a comment
let comment = try await client.request(
PostCommentEndpoint(
fileId: "your-file-id",
body: PostCommentBody(message: "Looks great!")
)
)

// List webhooks (v2 API)
let webhooks = try await client.request(GetWebhooksEndpoint())
```

## License
Expand Down
1 change: 1 addition & 0 deletions Sources/FigmaAPI/Endpoint/BaseEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ extension BaseEndpoint {
}
}
}

1 change: 1 addition & 0 deletions Sources/FigmaAPI/Endpoint/ComponentsEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public struct ComponentsEndpoint: BaseEndpoint {

public func makeRequest(baseURL: URL) -> URLRequest {
let url = baseURL
.appendingPathComponent("v1")
.appendingPathComponent("files")
.appendingPathComponent(fileId)
.appendingPathComponent("components")
Expand Down
34 changes: 34 additions & 0 deletions Sources/FigmaAPI/Endpoint/DeleteCommentEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation
import YYJSON
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct DeleteCommentEndpoint: BaseEndpoint {
public typealias Content = EmptyResponse

private let fileId: String
private let commentId: String

public init(fileId: String, commentId: String) {
self.fileId = fileId
self.commentId = commentId
}

public func makeRequest(baseURL: URL) -> URLRequest {
let url = baseURL
.appendingPathComponent("v1")
.appendingPathComponent("files")
.appendingPathComponent(fileId)
.appendingPathComponent("comments")
.appendingPathComponent(commentId)
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
return request
}

public func content(from _: URLResponse?, with body: Data) throws -> EmptyResponse {
if body.isEmpty { return EmptyResponse() }
return try YYJSONDecoder().decode(EmptyResponse.self, from: body)
}
}
34 changes: 34 additions & 0 deletions Sources/FigmaAPI/Endpoint/DeleteDevResourceEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation
import YYJSON
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct DeleteDevResourceEndpoint: BaseEndpoint {
public typealias Content = EmptyResponse

private let fileId: String
private let resourceId: String

public init(fileId: String, resourceId: String) {
self.fileId = fileId
self.resourceId = resourceId
}

public func makeRequest(baseURL: URL) -> URLRequest {
let url = baseURL
.appendingPathComponent("v1")
.appendingPathComponent("files")
.appendingPathComponent(fileId)
.appendingPathComponent("dev_resources")
.appendingPathComponent(resourceId)
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
return request
}

public func content(from _: URLResponse?, with body: Data) throws -> EmptyResponse {
if body.isEmpty { return EmptyResponse() }
return try YYJSONDecoder().decode(EmptyResponse.self, from: body)
}
}
44 changes: 44 additions & 0 deletions Sources/FigmaAPI/Endpoint/DeleteReactionEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Foundation
import YYJSON
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct DeleteReactionEndpoint: BaseEndpoint {
public typealias Content = EmptyResponse

private let fileId: String
private let commentId: String
private let emoji: String

public init(fileId: String, commentId: String, emoji: String) {
self.fileId = fileId
self.commentId = commentId
self.emoji = emoji
}

public func makeRequest(baseURL: URL) throws -> URLRequest {
let url = baseURL
.appendingPathComponent("v1")
.appendingPathComponent("files")
.appendingPathComponent(fileId)
.appendingPathComponent("comments")
.appendingPathComponent(commentId)
.appendingPathComponent("reactions")
guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
throw URLError(.badURL)
}
comps.queryItems = [URLQueryItem(name: "emoji", value: emoji)]
guard let finalURL = comps.url else {
throw URLError(.badURL)
}
var request = URLRequest(url: finalURL)
request.httpMethod = "DELETE"
return request
}

public func content(from _: URLResponse?, with body: Data) throws -> EmptyResponse {
if body.isEmpty { return EmptyResponse() }
return try YYJSONDecoder().decode(EmptyResponse.self, from: body)
}
}
30 changes: 30 additions & 0 deletions Sources/FigmaAPI/Endpoint/DeleteWebhookEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation
import YYJSON
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct DeleteWebhookEndpoint: BaseEndpoint {
public typealias Content = EmptyResponse

private let webhookId: String

public init(webhookId: String) {
self.webhookId = webhookId
}

public func makeRequest(baseURL: URL) -> URLRequest {
let url = baseURL
.appendingPathComponent("v2")
.appendingPathComponent("webhooks")
.appendingPathComponent(webhookId)
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
return request
}

public func content(from _: URLResponse?, with body: Data) throws -> EmptyResponse {
if body.isEmpty { return EmptyResponse() }
return try YYJSONDecoder().decode(EmptyResponse.self, from: body)
}
}
1 change: 1 addition & 0 deletions Sources/FigmaAPI/Endpoint/FileMetadataEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public struct FileMetadataEndpoint: BaseEndpoint {

public func makeRequest(baseURL: URL) throws -> URLRequest {
let url = baseURL
.appendingPathComponent("v1")
.appendingPathComponent("files")
.appendingPathComponent(fileId)

Expand Down
60 changes: 60 additions & 0 deletions Sources/FigmaAPI/Endpoint/GetActivityLogsEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct GetActivityLogsEndpoint: BaseEndpoint {
public typealias Content = [ActivityLog]

private let events: String?
private let startTime: Double?
private let endTime: Double?
private let limit: Int?
private let order: String?

public init(
events: String? = nil,
startTime: Double? = nil,
endTime: Double? = nil,
limit: Int? = nil,
order: String? = nil
) {
self.events = events
self.startTime = startTime
self.endTime = endTime
self.limit = limit
self.order = order
}

func content(from root: ActivityLogsResponse) -> [ActivityLog] {
root.activityLogs
}

public func makeRequest(baseURL: URL) throws -> URLRequest {
let url = baseURL
.appendingPathComponent("v1")
.appendingPathComponent("activity_logs")
guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
throw URLError(.badURL)
}
var items: [URLQueryItem] = []
if let events { items.append(URLQueryItem(name: "events", value: events)) }
if let startTime { items.append(URLQueryItem(name: "start_time", value: "\(startTime)")) }
if let endTime { items.append(URLQueryItem(name: "end_time", value: "\(endTime)")) }
if let limit { items.append(URLQueryItem(name: "limit", value: "\(limit)")) }
if let order { items.append(URLQueryItem(name: "order", value: order)) }
if !items.isEmpty { comps.queryItems = items }
guard let finalURL = comps.url else {
throw URLError(.badURL)
}
return URLRequest(url: finalURL)
}
}

struct ActivityLogsResponse: Decodable {
let activityLogs: [ActivityLog]

private enum CodingKeys: String, CodingKey {
case activityLogs = "activity_logs"
}
}
31 changes: 31 additions & 0 deletions Sources/FigmaAPI/Endpoint/GetCommentsEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct GetCommentsEndpoint: BaseEndpoint {
public typealias Content = [Comment]

private let fileId: String

public init(fileId: String) {
self.fileId = fileId
}

func content(from root: CommentsResponse) -> [Comment] {
root.comments
}

public func makeRequest(baseURL: URL) -> URLRequest {
let url = baseURL
.appendingPathComponent("v1")
.appendingPathComponent("files")
.appendingPathComponent(fileId)
.appendingPathComponent("comments")
return URLRequest(url: url)
}
}

struct CommentsResponse: Decodable {
let comments: [Comment]
}
33 changes: 33 additions & 0 deletions Sources/FigmaAPI/Endpoint/GetComponentActionsEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct GetComponentActionsEndpoint: BaseEndpoint {
public typealias Content = [LibraryAnalyticsAction]

private let fileKey: String

public init(fileKey: String) {
self.fileKey = fileKey
}

func content(from root: LibraryActionsResponse) -> [LibraryAnalyticsAction] {
root.actions
}

public func makeRequest(baseURL: URL) -> URLRequest {
let url = baseURL
.appendingPathComponent("v1")
.appendingPathComponent("analytics")
.appendingPathComponent("libraries")
.appendingPathComponent(fileKey)
.appendingPathComponent("component")
.appendingPathComponent("actions")
return URLRequest(url: url)
}
}

struct LibraryActionsResponse: Decodable {
let actions: [LibraryAnalyticsAction]
}
30 changes: 30 additions & 0 deletions Sources/FigmaAPI/Endpoint/GetComponentEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct GetComponentEndpoint: BaseEndpoint {
public typealias Content = Component

private let key: String

public init(key: String) {
self.key = key
}

func content(from root: GetComponentResponse) -> Component {
root.meta
}

public func makeRequest(baseURL: URL) -> URLRequest {
let url = baseURL
.appendingPathComponent("v1")
.appendingPathComponent("components")
.appendingPathComponent(key)
return URLRequest(url: url)
}
}

struct GetComponentResponse: Decodable {
let meta: Component
}
Loading