Super lightweight web framework in Swift based on SWSGI

Overview

Ambassador

Build Status CocoaPods Code Climate Issue Count GitHub license

Super lightweight web framework in Swift based on SWSGI

Features

  • Super lightweight
  • Easy to use, designed for UI automatic testing API mocking
  • Based on SWSGI, can run with HTTP server other than Embassy
  • Response handlers designed as middlewares, easy to composite
  • Async friendly

Example

Here's an example how to mock API endpoints with Ambassador and Embassy as the HTTP server.

import Embassy
import EnvoyAmbassador

let loop = try! SelectorEventLoop(selector: try! KqueueSelector())
let router = Router()
let server = DefaultHTTPServer(eventLoop: loop, port: 8080, app: router.app)

router["/api/v2/users"] = DelayResponse(JSONResponse(handler: { _ -> Any in
    return [
        ["id": "01", "name": "john"],
        ["id": "02", "name": "tom"]
    ]
}))

// Start HTTP server to listen on the port
try! server.start()

// Run event loop
loop.runForever()

Then you can visit http://[::1]:8080/api/v2/users in the browser, or use HTTP client to GET the URL and see

[
  {
    "id" : "01",
    "name" : "john"
  },
  {
    "id" : "02",
    "name" : "tom"
  }
]

Router

Router allows you to map different path to different WebApp. Like what you saw in the previous example, to route path /api/v2/users to our response handler, you simply set the desired path with the WebApp as the value

let router = Router()
router["/api/v2/users"] = DelayResponse(JSONResponse(handler: { _ -> Any in
    return [
        ["id": "01", "name": "john"],
        ["id": "02", "name": "tom"]
    ]
}))

and pass router.app as the SWSGI interface to the HTTP server. When the visit path is not found, router.notFoundResponse will be used, it simply returns 404. You can overwrite the notFoundResponse to customize the not found behavior.

You can also map URL with regular expression. For example, you can write this

let router = Router()
router["/api/v2/users/([0-9]+)"] = DelayResponse(JSONResponse(handler: { environ -> Any in
    let captures = environ["ambassador.router_captures"] as! [String]
    return ["id": captures[0], "name": "john"]
}))

Then all requests with URL matching /api/v2/users/([0-9]+) regular expression will be routed here. For all match groups, they will be passed into environment with key ambassador.router_captures as an array of string.

DataResponse

DataResponse is a helper for sending back data. For example, say if you want to make an endpoint to return status code 500, you can do

router["/api/v2/return-error"] = DataResponse(statusCode: 500, statusMessage: "server error")

Status is 200 OK, and content type is application/octet-stream by default, they all can be overwritten via init parameters. You can also provide custom headers and a handler for returning the data. For example:

router["/api/v2/xml"] = DataResponse(
    statusCode: 201,
    statusMessage: "created",
    contentType: "application/xml",
    headers: [("X-Foo-Bar", "My header")]
) { environ -> Data in
    return Data("<xml>who uses xml nowadays?</xml>".utf8)
}

If you prefer to send body back in async manner, you can also use another init that comes with extra sendData function as parameter

router["/api/v2/xml"] = DataResponse(
    statusCode: 201,
    statusMessage: "created",
    contentType: "application/xml",
    headers: [("X-Foo-Bar", "My header")]
) { (environ, sendData) in
    sendData(Data("<xml>who uses xml nowadays?</xml>".utf8))
}

Please notice, unlike sendBody for SWSGI, sendData only expects to be called once with the whole chunk of data.

JSONResponse

Almost identical to DataResponse, except it takes Any instead of bytes and dump the object as JSON format and response it for you. For example:

router["/api/v2/users"] = JSONResponse() { _ -> Any in
    return [
        ["id": "01", "name": "john"],
        ["id": "02", "name": "tom"]
    ]
}

DelayResponse

DelayResponse is a decorator response that delays given response for a while. In real-world, there will always be network latency, to simulte the latency, DelayResponse is very helpful. To delay a response, just do

router["/api/v2/users"] = DelayResponse(JSONResponse(handler: { _ -> Any in
    return [
        ["id": "01", "name": "john"],
        ["id": "02", "name": "tom"]
    ]
}))

By default, it delays the response randomly. You can modify it by passing delay parameter. Like, say if you want to delay it for 10 seconds, then here you do

router["/api/v2/users"] = DelayResponse(JSONResponse(handler: { _ -> Any in
    return [
        ["id": "01", "name": "john"],
        ["id": "02", "name": "tom"]
    ]
}), delay: .delay(10))

The available delay options are

  • .random(min: TimeInterval, max: TimeInterval) delay random, it's also the default one as .random(min: 0.1, max: 3)
  • .delay(seconds: TimeInterval) delay specific period of time
  • .never delay forever, the response will never be returned
  • .none no delay, i.e. the response will be returned immediately

DataReader

To read POST body or any other HTTP body from the request, you need to use swsgi.input function provided in the environ parameter of SWSGI. For example, you can do it like this

router["/api/v2/users"] = JSONResponse() { environ -> Any in
    let input = environ["swsgi.input"] as! SWSGIInput
    input { data in
        // handle the data stream here
    }
}

It's not too hard to do so, however, the data comes in as stream, like

  • "first chunk"
  • "second chunk"
  • ....
  • "" (empty data array indicates EOF)

In most cases, you won't like to handle the data stream manually. To wait all data received and process them at once, you can use DataReader. For instance

router["/api/v2/users"] = JSONResponse() { environ -> Any in
    let input = environ["swsgi.input"] as! SWSGIInput
    DataReader.read(input) { data in
        // handle the whole data here
    }
}

JSONReader

Like DataReader, besides reading the whole chunk of data, JSONReader also parses it as JSON format. Herer's how you do

router["/api/v2/users"] = JSONResponse() { environ -> Any in
    let input = environ["swsgi.input"] as! SWSGIInput
    JSONReader.read(input) { json in
        // handle the json object here
    }
}

URLParametersReader

URLParametersReader waits all data to be received and parses them all at once as URL encoding parameters, like foo=bar&eggs=spam. The parameters will be passed as an array key value pairs as (String, String).

router["/api/v2/users"] = JSONResponse() { environ -> Any in
    let input = environ["swsgi.input"] as! SWSGIInput
    URLParametersReader.read(input) { params in
        // handle the params object here
    }
}

You can also use URLParametersReader.parseURLParameters to parse the URL encoded parameter string if you want. Just do it like

let params = URLParametersReader.parseURLParameters("foo=bar&eggs=spam")

Install

CocoaPods

To install with CocoaPod, add Embassy to your Podfile:

pod 'EnvoyAmbassador', '~> 4.0'

Carthage

To install with Carthage, add Ambassador to your Cartfile:

github "envoy/Ambassador" ~> 4.0

Please notice that you should import Ambassador instead of EnvoyAmbassador. We use EnvoyAmbassador for Cocoapods simply because the name Ambassador was already taken.

Package Manager

To do this, add the repo to Package.swift, like this:

import PackageDescription

let package = Package(
    name: "AmbassadorExample",
    dependencies: [
        .package(url: "https://github.com/envoy/Ambassador.git",
                 from: "4.0.0"),
    ]
)
Comments
  • Hanging redirects

    Hanging redirects

    I noticed that stubbing requests with redirect responses makes server to "hang" with these stubs making it impossible to override this stub with another response. That does not happen for simple responses though. I was able to override it only after I deleted UI tests target from simulator.

    Here is how I stub request with redirect (the reset of the code is taken from the blog article):

    let response = DataResponse(statusCode: 301, statusMessage: "Moved Permanently", headers: [("Location", "http://www.example.org/")])
    router[DefaultRouter.fetchUsersPath] = DelayResponse(response)
    

    Even if I comment out these lines and run the test again I keep receiving redirect response.

    opened by ilyapuchka 6
  • Response body gets cut off

    Response body gets cut off

    When trying to get all the data from response body with DataReader it works fine when receiving small amount of data, but when trying to get a bigger body response, it doesn’t return the whole data, but only a part of it. I could fix it by increasing recvChunkSize to the size bigger that I was trying to read. But seems like the problem is in chunking itself, but I wasn’t able to debug it more deeply.

    Has anyone encountered this kind of problem?

    opened by bohdankoshyrets 5
  • App Lockup

    App Lockup

    Using the exact example code, after running the server..the app freezes, but the server is still accessible. Any ideas as to what may be going wrong? If I comment out loop.runForever() it doesn't happen..but the server doesn't work. using Xcode 9 on iOS 11 Swift 3.

    import Embassy
    import EnvoyAmbassador
    
    let loop = try! SelectorEventLoop(selector: try! KqueueSelector())
    let router = Router()
    let server = DefaultHTTPServer(eventLoop: loop, port: 8080, app: router.app)
    
    router["/api/v2/users"] = DelayResponse(JSONResponse(handler: { _ -> Any in
        return [
            ["id": "01", "name": "john"],
            ["id": "02", "name": "tom"]
        ]
    }))
    
    // Start HTTP server to listen on the port
    try! server.start()
    
    // Run event loop
    loop.runForever()```
    opened by skram 4
  • Swift 5 migration

    Swift 5 migration

    It seems there wasn't much to do to migrate this to Swift 5, but since this is the first time I propose a PR to a foreign repo I am not sure whether I perhaps forgot something.

    Besides the normal project file updates I would also propose this to increase the version of the pod to 4.1 as I am not sure it is fully backwards compatible. Thus I changed that in the podspec (together with specifying the swift version there instead of a .swift-version file).

    opened by GeroHerkenrath 3
  • Double sendBody in DataResponse

    Double sendBody in DataResponse

    Just curious, is this supposed to call sendBody with an empty Data? I don't see any comments around it why it would do that.

    https://github.com/envoy/Ambassador/blob/master/Ambassador/Responses/DataResponse.swift#L81

                if !data.isEmpty {
                    sendBody(data)
                }
    -->         sendBody(Data())
    
    opened by andyvanosdale 3
  • Reading request data

    Reading request data

    As readme says to read request data we can use DataRead.read(input) { data in ... } in request handler. This does not work for me as reading block is called asynchronously after request handler returns, so I can not use it to build data to send back. If I try to wait for this block without exiting handler connections is getting stuck.

    What I want to accomplish is to redirect all requests that are not stubbed to real server (because it's just insane to stub every request in the flow when you need to test one particular case on the screen that is deep inside navigation and app needs to make dozen of requests before reaching it). Here is my code:

    struct RedirectResponse: WebApp {
        
        // where to redirect
        let host: String
        let scheme: String
        
        func urlRequest(_ environ: [String: Any]) -> URLRequest {
            //reading environment variables and transforming header keys to their initial values....
            return request
        }
        
        public func app(_ environ: [String : Any], startResponse: @escaping ((String, [(String, String)]) -> Swift.Void), sendBody: @escaping ((Data) -> Swift.Void)) {
            var request = urlRequest(environ)
            let input = environ["swsgi.input"] as! SWSGIInput
            DataReader.read(input) { data in
           // this block is called only after method returns, which is too late as request was already fired.
           // Waiting for this block to be called before firing request keeps connection stuck and block is never called
                request.httpBody = data 
            }
            // makes request and wait for response in synchronous manner, using `group.wait()`
            let (data, statusCode, contentType, headers) = URLSession.shared.data(with: request)
            //send data from real server back
            DataResponse(statusCode: statusCode, statusMessage: "", contentType: contentType, headers: headers, handler: { (_, sendData) in
                sendData(data)
            }).app(environ, startResponse: startResponse, sendBody: sendBody)
        }
        
    }
    

    Then I set this response as notFoundResponse for the router.

    With that I receive such logs:

    2017-02-03 13:03:58 +0000 [info] - DefaultHTTPServer: New connection 87A6CC41-5E67-4585-944D-C409FDC9D16A from [::1]:58322
    2017-02-03 13:03:58 +0000 [info] - HTTPConnection: [87A6CC41-5E67-4585-944D-C409FDC9D16A] Header parsed, method=Optional("POST"), path="/auth/oauth2/client/access_token", version="HTTP/1.1", headers=[("Host", "localhost:8080"), ("Accept-Encoding", "gzip;q=1.0, compress;q=0.5"), ("mobile-app", "IOS-838"), ("Accept-Language", "en-US;q=1.0, en;q=0.9"), ("Content-Length", "177"), ("Content-Type", "application/json"), ("Accept", "*/*"), ("Connection", "keep-alive"), ("User-Agent", "Alamofire/4.2.0")]
    2017-02-03 13:03:58 +0000 [info] - HTTPConnection: [87A6CC41-5E67-4585-944D-C409FDC9D16A] Resume reading
    2017-02-03 13:03:58 +0000 [info] - HTTPConnection: [87A6CC41-5E67-4585-944D-C409FDC9D16A] Finish response
    2017-02-03 13:03:58 +0000 [info] - HTTPConnection: [87A6CC41-5E67-4585-944D-C409FDC9D16A] Connection closed, reason=byLocal
    

    Request fails as it does not contain required body data.

    When trying to wait for read data block:

    2017-02-03 13:01:35 +0000 [info] - DefaultHTTPServer: New connection 6DB9C3DF-02E3-49F5-8801-22EB0CE58AF5 from [::1]:58286
    2017-02-03 13:01:35 +0000 [info] - HTTPConnection: [6DB9C3DF-02E3-49F5-8801-22EB0CE58AF5] Header parsed, method=Optional("POST"), path="/auth/oauth2/client/access_token", version="HTTP/1.1", headers=[("Host", "localhost:8080"), ("Accept-Encoding", "gzip;q=1.0, compress;q=0.5"), ("mobile-app", "IOS-838"), ("Accept-Language", "en-US;q=1.0, en;q=0.9"), ("Content-Length", "177"), ("Content-Type", "application/json"), ("Accept", "*/*"), ("Connection", "keep-alive"), ("User-Agent", "Alamofire/4.2.0")]
    2017-02-03 13:01:35 +0000 [info] - HTTPConnection: [6DB9C3DF-02E3-49F5-8801-22EB0CE58AF5] Resume reading
    

    and then request fails with timeout.

    Here is the request that app makes:

    curl -i \
    	-X POST \
    	-H "mobile-app: IOS-838" \
    	-H "Content-Type: application/json" \
    	-H "Accept-Encoding: gzip;q=1.0, compress;q=0.5" \
    	-H "Accept-Language: en-US;q=1.0, en;q=0.9" \
    	-H "User-Agent: Alamofire/4.2.0" \
    	-d "{\"country\":\"us\",\"locale\":\"en-US\",\"grant_type\":\"client_credentials\",\"client_id\":\"...\",\"client_secret\":\"...\",\"scope\":\"public\"}" \
    	"http://localhost:8080/auth/oauth2/client/access_token"
    
    opened by ilyapuchka 3
  • Get query string from a request

    Get query string from a request

    I'm trying to get the query string arguments from an URL similar to: /v2/users?direction=past&limit=20. Is there a way to get those parameters and their values inside a router to form response based on those parameters? I did try to use URLParametersReader, but it returns an empty object every time.

    opened by bohdankoshyrets 2
  • Update podSpec to specify tvOS support

    Update podSpec to specify tvOS support

    Resolve validation error on pod repo push or install for tvOS apps.

    • ERROR | [tvOS] unknown: Encountered an unknown error (The platform of the target App (tvOS 11.0) is not compatible with EnvoyAmbassador (4.0.3), which does not support tvos.) during validation.
    opened by Joe-Spectrum 2
  • Possible to serve JSON files?

    Possible to serve JSON files?

    Ambassador is a great project - I've been using it for testing and I love it.

    Is it possible to serve JSON directly from a file, similar to how json-server works?

    opened by damianesteban 2
  • Swift PM and Linux Support

    Swift PM and Linux Support

    Hi,

    I'd like to participate in this project by adding support for Swift Package Manager and some fixes for Linux os.

    Please let me know when i missed something.

    Thanks

    opened by cpageler93 1
  • Update cocoapods version

    Update cocoapods version

    Aloha Envoy team!

    The cocoapods version is still a tiny bit behind, it doesn't include @fangpenlin's PR #28. Since my project uses cocoapods I still get a warning during builds (that's the only one, so kinda prominent).

    I had actually already forked the repo and wanted to fix the deprecated call to characters, I was really confused when master already had the latest Swift syntax. 😸

    Maybe one of you can quickly do a tag, version bump etc. and push to the cocoapods spec repo?

    Thanks a lot, your Embassy & Ambassador have really helped me a lot in testing (and since it's all swift I can keep my repo nice and clean!).

    opened by GeroHerkenrath 1
  • Fatal error with xcode 14 with Bad file descriptor

    Fatal error with xcode 14 with Bad file descriptor

    Hi all, after upgrading to xcode 14 we are getting a error on both M1 rossetta and intel:

    Embassy/SelectorEventLoop.swift:88: Fatal error: 'try!' expression unexpectedly raised an error: Embassy.OSError.ioError(number: 9, message: "Bad file descriptor")

    This happens after trying to restart (teardown-setup) a local server image

    testing with multiple iOS versions, testing with other xcode versions later.

    Anyone else run into this?

    opened by D3icidal 0
  • Providing path to json file instead of content of the json.

    Providing path to json file instead of content of the json.

    Instead of providing the JSON format structure after return, is there a way to point Ambassador to the .json file located in the project of the app we want to test?

    instead of

    router["/api/v2/users"] = JSONResponse() { _ -> Any in
        return [
            ["id": "01", "name": "john"],
            ["id": "02", "name": "tom"]
        ]
    }
    

    something like:

    router["/api/v2/users"] = JSONResponse() { _ -> Any in
        return [
           path/to/file/users.json
        ]
    }
    
    opened by bartzet 0
  • Retrieving request Headers through Router

    Retrieving request Headers through Router

    Is it possible to get request headers using Ambassador? I can't seem to find them in the environ variable alongside the other information.

    I expected to find custom headers or most common ones inside a environ field.

    opened by DevDema 0
  • Delay response doesnt work as expected

    Delay response doesnt work as expected

    Hello guys,

    I noticed that when I use delay response in ui test, and in this response I return some value to assert in UI test, delayed response sometimes come after I made assertion.

    So, first I mock response with delay like this router["/api/v2/users"] = DelayResponse(JSONResponse(......)), and after I assert value I got from response file, or assert ui element that I enabled in this mocked response. So this kind of tests are flaky, because depending on delay time, it gives back response after assertion.

    Deleting delay response solves this flakiness. But why it happens with DelayResponse?

    opened by ZhanatM 0
  • Support many states api responses

    Support many states api responses

    Hello.

    Is there any way to support many states API responses such as:

    • Call Login success will be responded:
    router["/api/v2/login"] = DelayResponse(JSONStringResponse(handler: { _ -> Any in
        return """
            {
              "acess_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
            }
        """
    }))
    
    • Call login failed will be responded:
    router["/api/v2/login"] = DelayResponse(JSONStringResponse(handler: { _ -> Any in
        return """
            {
              "message": "incorrect password"
            }
        """
    }))
    

    I mean I want to test a feature (login) with many cases as many API responses. Seem to now only one state can setup before a test.

    Thank you so much.

    opened by nvduc2910 0
Releases(v4.0.5)
  • v4.0.5(Oct 10, 2018)

    In the previous release we had v4.0.4 tag on wrong commit, as a result the version number in Podspec file was wrong. This release just bumps the version and make a new release.

    Source code(tar.gz)
    Source code(zip)
  • v4.0.4(Sep 17, 2018)

  • v4.0.3(Sep 14, 2018)

  • v4.0.2(Jun 21, 2018)

    Remove calls to deprecated property

    https://github.com/envoy/Ambassador/pull/28

    Thanks @JanNash for contribution.

    Also thank @GeroHerkenrath for remind us about this new release

    https://github.com/envoy/Ambassador/issues/34

    Source code(tar.gz)
    Source code(zip)
Owner
Envoy
Transforming modern workplaces and challenging the status quo with innovations that make office life and work more meaningful
Envoy
Lightweight library for web server applications in Swift on macOS and Linux powered by coroutines.

Why Zewo? • Support • Community • Contributing Zewo Zewo is a lightweight library for web applications in Swift. What sets Zewo apart? Zewo is not a w

Zewo 1.9k Dec 22, 2022
A Swift web framework and HTTP server.

A Swift Web Framework and HTTP Server Summary Kitura is a web framework and web server that is created for web services written in Swift. For more inf

Kitura 7.6k Dec 27, 2022
A Swift Multiplatform Single-threaded Non-blocking Web and Networking Framework

Serverside non-blocking IO in Swift Ask questions in our Slack channel! Lightning (formerly Edge) Node Lightning is an HTTP Server and TCP Client/Serv

SkyLab 316 Oct 6, 2022
💧 A server-side Swift web framework.

Vapor is a web framework for Swift. It provides a beautifully expressive and easy to use foundation for your next website, API, or cloud project. Take

Vapor 22.4k Jan 7, 2023
Server-side Swift. The Perfect core toolset and framework for Swift Developers. (For mobile back-end development, website and API development, and more…)

Perfect: Server-Side Swift 简体中文 Perfect: Server-Side Swift Perfect is a complete and powerful toolbox, framework, and application server for Linux, iO

PerfectlySoft Inc. 13.9k Dec 29, 2022
Meet Corvus, the first strongly declarative server-side framework.

Corvus Corvus is the first truly declarative server-side framework for Swift. It provides a declarative, composable syntax which makes it easy to get

null 42 Jun 29, 2022
Swift HTTP server using the pre-fork worker model

Curassow Curassow is a Swift Nest HTTP Server. It uses the pre-fork worker model and it's similar to Python's Gunicorn and Ruby's Unicorn. It exposes

Kyle Fuller Archive 397 Oct 30, 2022
Evented I/O streams for Swift

Noze.io "Das Haus das Verrückte macht." Noze.io is an attempt to carry over the Node.js ideas into pure Swift. It uses libdispatch for event-driven, n

The Noze Consortium 305 Oct 14, 2022
Tiny http server engine written in Swift programming language.

What is Swifter? Tiny http server engine written in Swift programming language. Branches * stable - lands on CocoaPods and others. Supports the latest

null 3.6k Jan 3, 2023
Super lightweight web framework in Swift based on SWSGI

Ambassador Super lightweight web framework in Swift based on SWSGI Features Super lightweight Easy to use, designed for UI automatic testing API mocki

Envoy 170 Nov 15, 2022
Super lightweight DB written in Swift.

Use of value types is recommended and we define standard values, simple structured data, application state and etc. as struct or enum. Pencil makes us store these values more easily.

Naruki Chigira 88 Oct 22, 2022
Super lightweight async HTTP server library in pure Swift runs in iOS / MacOS / Linux

Embassy Super lightweight async HTTP server in pure Swift. Please read: Embedded web server for iOS UI testing. See also: Our lightweight web framewor

Envoy 540 Dec 15, 2022
Super lightweight async HTTP server library in pure Swift runs in iOS / MacOS / Linux

Embassy Super lightweight async HTTP server in pure Swift. Please read: Embedded web server for iOS UI testing. See also: Our lightweight web framewor

Envoy 540 Dec 15, 2022
Super lightweight DB written in Swift.

Use of value types is recommended and we define standard values, simple structured data, application state and etc. as struct or enum. Pencil makes us

Naruki Chigira 88 Oct 22, 2022
Super lightweight async HTTP server library in pure Swift runs in iOS / MacOS / Linux

Embassy Super lightweight async HTTP server in pure Swift. Please read: Embedded web server for iOS UI testing. See also: Our lightweight web framewor

Envoy 540 Dec 15, 2022
🎗 Super lightweight ISO8601 Date Formatter in Swift

ISO8601 ❤️ Support my apps ❤️ Push Hero - pure Swift native macOS application to test push notifications PastePal - Pasteboard, note and shortcut mana

Khoa 19 May 12, 2020
🎗 Super lightweight ISO8601 Date Formatter in Swift

ISO8601 ❤️ Support my apps ❤️ Push Hero - pure Swift native macOS application to test push notifications PastePal - Pasteboard, note and shortcut mana

Khoa 19 May 12, 2020
Super-lightweight library to detect used device

Device.swift Super-lightweight library to detect used device Device.swift extends the UIDevice class by adding a property: var deviceType: DeviceType

Johannes Schickling 219 Nov 17, 2022
Super lightweight library that helps you to localize strings, even directly in storyboards!

Translatio Example To run the example project, clone the repo, and run pod install from the Example directory first. Requirements iOS 9 or higher. Swi

Andrea Mario Lufino 19 Jan 29, 2022
Super-lightweight library to detect used device

Device.swift Super-lightweight library to detect used device Device.swift extends the UIDevice class by adding a property: var deviceType: DeviceType

Johannes Schickling 219 Nov 17, 2022