{"id":19272642,"url":"https://github.com/grype/swiftethel","last_synced_at":"2026-05-13T21:34:46.676Z","repository":{"id":43888934,"uuid":"232425164","full_name":"grype/SwiftEthel","owner":"grype","description":"A lightweight framework for composing web service clients in Swift","archived":false,"fork":false,"pushed_at":"2023-03-30T00:25:20.000Z","size":258,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":3,"default_branch":"main","last_synced_at":"2025-02-23T20:41:28.097Z","etag":null,"topics":["api","api-client","framework","graphql","rest-api","swift","webservice-client","webservice-framework"],"latest_commit_sha":null,"homepage":"","language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/grype.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2020-01-07T21:58:12.000Z","updated_at":"2022-12-14T23:10:36.000Z","dependencies_parsed_at":"2024-11-09T20:37:40.259Z","dependency_job_id":"88e10928-a20a-42f2-8184-d03fc390c99b","html_url":"https://github.com/grype/SwiftEthel","commit_stats":null,"previous_names":[],"tags_count":8,"template":false,"template_full_name":null,"purl":"pkg:github/grype/SwiftEthel","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/grype%2FSwiftEthel","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/grype%2FSwiftEthel/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/grype%2FSwiftEthel/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/grype%2FSwiftEthel/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/grype","download_url":"https://codeload.github.com/grype/SwiftEthel/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/grype%2FSwiftEthel/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33001169,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-13T13:14:54.681Z","status":"ssl_error","status_checked_at":"2026-05-13T13:14:51.610Z","response_time":115,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api","api-client","framework","graphql","rest-api","swift","webservice-client","webservice-framework"],"created_at":"2024-11-09T20:37:30.526Z","updated_at":"2026-05-13T21:34:46.657Z","avatar_url":"https://github.com/grype.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Ethel\n\n[![CI](https://github.com/grype/SwiftEthel/actions/workflows/swift.yml/badge.svg)](https://github.com/grype/SwiftEthel/actions/workflows/swift.yml)\n\nLightweight framework for composing web service clients in Swift. It encourages to reason about web services in terms of logical structures, and promotes clean and easy to maintain architecture.\n\nEthel has a simple architecture that is able to support a wide range of APIs, including REST and GraphQL.  It can be used to write complete SDKs of varying complexity.\n\n## Installing\n\nThis is a swift package. Use swift package manager to add to a project...\n\n## Logging\n\nEthel uses [Beacon](https://github.com/grype/SwiftBeacon) for logging. To start logging, simply create a logger and start it on `Beacon.ethel` object - which is the default `Beacon` on which the framework emits signals.\n\n``` swift\nlet logger = ConsoleLogger(name: \"Playground\")\nlogger.start(on: [Beacon.ethel])\n```\n\n## Example\n\nLet's take GitHub's Gists API for example. We'll start by defining the client class.\n\n### The Client\n\n```swift\nstruct GHClientConfiguration {\n    var url: URL\n    var authToken: String?\n    static var `default` = GHClientConfiguration(url: URL(string: \"https://api.github.com/\")!, authToken: nil)\n}\n\nclass GHClient : Client {\n    override var baseUrl: URL? { return configuration.url }\n    \n    static var `default` = GHClient(configuration: GHClientConfiguration.default)\n    \n    var configuration: GHClientConfiguration\n    \n    init(configuration aConfig: GHClientConfiguration) {\n        configuration = aConfig\n        super.init(url: aConfig.url, sessionConfiguration: URLSessionConfiguration.background(withIdentifier: \"GHClient\"))\n    }\n}\n```\n\nThe client needs to be initialized with a base URL of the web service, and a `URLSessionConfiguration` to use for an internally managed `URLSession`. In this particular case, we capture the base URL in the `GHClientConfiguration`, and use a background session configuration.\n\n### The Endpoints\n\nNext, we need to define an endpoint for interfacing with gists. It's a good idea to keep one endpoint class for a particular endpoint (i.e. one class for describing all of the API at the /gists level). Use instance variables to capture various parameters that the endpoint accepts.\n\nHere we define a simple endpoint for /gists, and a method for retrieving a single gist:\n\n```swift\nclass GHGistsEndpoint : Endpoint {\n    \n    override class var path: Path { Path() / \"gists\" }\n    \n    func gist(withId id: String) async throws -\u003e GHGist {\n        try await getJSON(decoder: nil) { (transport) in\n            transport.request?.url?.appendPathComponent(id)\n        }\n    }\n    \n}\n```\n\nClass-side var `path` returns a path into the web service starting with the client's baseUrl. The method `gist(withId:)` returns a `Promise` which can be used to retrieve the actual value, handle an error, map response to whatever you want, etc. The method retrieves a JSON structure that is converted to `GHGist`, which looks something like this:\n\n```swift\nstruct GHGist : Codable {\n    var id: String?\n    var url: URL?\n    enum CodingKeys: String, CodingKey {\n        case id, url\n    }\n}\n```\n\nNothing special there - just a Codable struct.\n\nWhen calling `getJSON`, you can provide your own instance of `JSONDecoder`, otherwise, a default `JSONDecoder()` instance will be used.\n\nThe last argument to `getJSON` is a block that will be given an instance of `Transporter`, which encapsulates both, the request and the response. The optional block gives you a chance to configure it, and in the above example, we're modifying the request URL by appending a URL component to access the gist with the given identifier.\n\nLastly, let's make this endpoint accessible via the client:\n\n```swift\nextension GHClient {\n  var gists : GHGistsEndpoint {\n    return self / GHGistsEndpoint.self\n  }\n```\n\nAt this point we can ask the web service for a gist by ID:\n\n```swift\nlet client = GHClient.default\nclient\n  .gists\n  .gist(withId: \"...\")\n  .done { (gist) in\n    // do something about that gist\n  }\n```\n\nBoth, the client and an endpoint get to configure the `Transport` at the time of execution, via `configure(on aTransport: Transport)` method. Which makes it easy to configure requests for all the endpoints, via the client, and per individual endpoint, say adding query items to URL based on instance variables. This is done prior to evaluating the optional block passed to getJSON() method - which is your last opportunity to modify the `Transport` before the client makes a request.\n\nLet's create another endpoint for /gists/public:\n\n```swift\nclass GHPublicGistsEndpoint : Endpoint {\n    \n    override class var path : Path { GHGistsEndpoint.path / \"public\" }\n    \n    var since: Date?\n    \n    func configure(on aTransport: Transport) {\n        if let since = since {\n            aTransport.add(queryItem: URLQueryItem(name: \"since\", value: dateFormatter.string(from: since)))\n        }\n    }\n    \n    func list() -\u003e Promise\u003c[GHGist]\u003e {\n        return getJSON()\n    }\n```\n\nLet's connect it via the /gists endpoint:\n\n```swift\nextension GHGistsEndpoint {\n  var `public` : GHPublicGistsEndpoint {\n    return self / GHPublicGistsEndpoint.self\n  }\n}\n```\n\nAnd, finally, list some public gists:\n\n```swift\nlet endpoint = client.gists.public\nendpoint.since = Date().addingTimeInterval(-86400)\nendpoint.list().done { (gists) in\n  // do something\n}\n```\n\n### Enumeration\n\nIt just happens that the /gists/public endpoint is paginated. By using `page` and `per_page` URL queries we can enumerate over a collection of gists, access specific ranges of items. All we need to do is make the endpoint behave as a `Sequence`, using an iterator that captures this information:\n\n```swift\nstruct GHIterator\u003cU: SequenceEndpoint\u003e : EndpointIterator {\n    \n    typealias Element = U.Element\n    \n    var endpoint: U\n    \n    var hasMore: Bool = true\n    \n    var page: Int = 1\n    \n    var pageSize: Int = 5\n    \n    private var currentOffset: Int = 0\n    \n    private var elements: [Element]?\n    \n    init(_ anEndpoint: U) {\n        endpoint = anEndpoint\n    }\n    \n    private var needsFetch: Bool {\n        guard hasMore else { return false }\n        return elements == nil || currentOffset \u003e= elements!.count\n    }\n    \n    mutating func next() -\u003e Element? {\n        guard hasMore else { return nil }\n        if needsFetch {\n            fetch()\n        }\n        \n        guard let elements = elements, elements.count \u003e currentOffset else {\n            return nil\n        }\n        let result = elements[currentOffset]\n        currentOffset += 1\n        return result\n    }\n    \n    private mutating func fetch() {\n        currentOffset = 0\n        do {\n            elements = try endpoint.next(with: self as! U.Iterator).wait()\n            hasMore = (elements?.count ?? 0) == pageSize\n            page += 1\n        } catch {\n            print(\"Error: \\(error)\")\n        }\n    }\n}\n```\n\nThe iterator maintains a variable `hasMore` indicating whether there's more results to fetch, and `next()` method to return the next element. There, we feed in a page of results at a time, and iterate over the results one by one. Fetching is done by calling `SequenceEndpoint.next(with:)` with the configured iterator. After fetching the page, we simply increment the current page number...\n\nFinally, extend `GHPublicGistsEndpoint` to conform to `SequenceEndpoint`:\n\n```swift\nextension GHPublicGistsEndpoint : SequenceEndpoint {\n    \n    typealias Iterator = GHIterator\u003cGHPublicGistsEndpoint\u003e\n    typealias Element = GHGist\n    \n    func makeIterator() -\u003e Iterator {\n        return GHIterator(self)\n    }\n    \n    func next(with iterator: Iterator) -\u003e Promise\u003c[GHGist]\u003e {\n        return getJSON() { (transport) in\n            transport.add(queryItem: URLQueryItem(name: \"page\", value: \"\\(iterator.page)\"))\n            transport.add(queryItem: URLQueryItem(name: \"per_page\", value: \"\\(iterator.pageSize)\"))\n        }\n    }\n  \n}\n```\n\nNow, we can query the web service as if it was a Sequence:\n\n```swift\nDispatchQueue.global(qos: .background).async {\n  client\n    .gists\n    .public\n    .forEach { (gist) in\n      // do something\n    }\n}\n```\n\nThis will cause the client to make additional requests to the web service as needed. Convenient, but notice that this will continue to make requests until we go through all of them, which is often not the desired behavior. For that reason, all of the `Sequence`'s enumeration methods are complimented with limiting variants, like:\n\n```swift\nDispatchQueue.global(qos: .background).async {\n  client\n    .gists\n    .public\n    .forEach(limit: 10) { (gist) in\n      // do something\n    }\n}\n```\n\nor more generally speaking:\n\n```swift\nDispatchQueue.global(qos: .background).async {\n  client\n    .gists\n    .public\n    .forEach(until: { (gists) -\u003e Bool\n      // return true when we need to bail\n    }) { (gist) in\n      // do something\n    }\n}\n```\n\nBecause all of these methods require making a network request and processing the response synchronously, we are dispatching the process into a background thread. This is possible because we can:\n\n```swift\nDispatchQueue.global(qos: .background).async {\n  var iterator = client.gists.public.makeIterator()\n  while iterator.hasMore {\n    let nextItem = iterator.next()\n  }\n}\n```\n\nThis also make it possible to fetch data using subscripts:\n\n```swift\nextension GHPublicGistsEndpoint {\n  subscript(index: Int) -\u003e GHGist? {\n    var iterator = makeIterator()\n    iterator.pageSize = 1\n    iterator.page = index + 1\n    return iterator.next()\n  }\n    \n  subscript(range: Range\u003cInt\u003e) -\u003e [GHGist] {\n    var iterator = makeIterator()\n    iterator.page = Int(floor(Double(range.lowerBound / iterator.pageSize))) + 1\n    var result = [GHGist]()\n    while iterator.hasMore, result.count \u003c range.upperBound - range.lowerBound {\n      guard let found = try? next(with: iterator).wait() else { break }\n\n      let startOffset = (iterator.page - 1) * iterator.pageSize\n      let endOffset = startOffset + iterator.pageSize - 1\n\n      let low = Swift.max(range.lowerBound - startOffset, 0)\n      let high = iterator.pageSize - Swift.max(endOffset - range.upperBound, 0)\n\n      result.append(contentsOf: found[low..\u003chigh])\n      iterator.page += 1\n    }\n    return result\n  }\n}\n```\n\nLet's give it a try:\n\n```swift\nDispatchQueue.global(qos: .background).async {\n  let gist = client.gists.public[index]\n  let someGists = client.gists.public[2..\u003c42]\n}\n```\n\nThis is it, for now...\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgrype%2Fswiftethel","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fgrype%2Fswiftethel","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgrype%2Fswiftethel/lists"}