diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml new file mode 100644 index 0000000..bbde19b --- /dev/null +++ b/.github/workflows/build-package.yml @@ -0,0 +1,40 @@ +name: Build Package + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize] + branches: [main, feature/**, fix/**, ci/**, docs/**, misc/**] + +jobs: + build-and-test: + name: Build for ${{ matrix.xcode-version }} + strategy: + matrix: + xcode-version: ['Xcode_16.4', 'Xcode_26_beta'] + runs-on: macos-15 + env: + DEVELOPER_DIR: /Applications/${{ matrix.xcode-version }}.app/Contents/Developer + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Swift Version + run: echo "$(swift --version | head -n 1)" + + - name: SwiftLint + run: | + echo "Installing SwiftLint..." + brew install swiftlint 2>/dev/null && true + + echo "SwiftLint: v$(swiftlint version)" || echo "SwiftLint is NOT installed" + + echo "Linting Swift files..." + swiftlint lint --strict + + - name: Build + run: swift build + + - name: Test + run: swift test --enable-code-coverage diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3a833f1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,66 @@ +name: Release + +on: + push: + branches: + - main + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.ACCESS_TOKEN }} + + - name: Create New Release Version + uses: TriPSs/conventional-changelog-action@v6 + id: changelog + with: + github-token: ${{ secrets.ACCESS_TOKEN }} + skip-version-file: 'true' + skip-commit: 'true' + tag-prefix: '' + release-count: 0 + output-file: 'CHANGELOG.md' + fallback-version: '1.0.0' + git-push: false + + - name: Update Version in Docs + if: ${{ steps.changelog.outputs.skipped == 'false' }} + run: | + echo "Configuring git locally..." + git config --local user.name "${{ secrets.USERNAME }}" + git config --local user.email "${{ secrets.EMAIL }}" + + echo "Updating README file..." + sed -E -i "s/(from: \"([0-9]+\.[0-9]+\.[0-9])\")/from: \"${{ steps.changelog.outputs.tag }}\"/g" README.md + git add README.md + + echo "Adding CHANGELOG file..." + git add CHANGELOG.md + + echo "Committing changes..." + git commit -m "release: ${{ steps.changelog.outputs.tag }} [skip ci]" + + echo "Pushing changes to remote..." + git push origin + + echo "Creating tag [${{ steps.changelog.outputs.tag }}]..." + git tag -d "${{ steps.changelog.outputs.tag }}" + git tag "${{ steps.changelog.outputs.tag }}" + + echo "Pushing tag to remote..." + git push origin ${{ steps.changelog.outputs.tag }} + + - name: Create Release + if: ${{ steps.changelog.outputs.skipped == 'false' }} + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.changelog.outputs.tag }} + name: ${{ steps.changelog.outputs.tag }} + body: ${{ steps.changelog.outputs.clean_changelog }} + token: ${{ secrets.ACCESS_TOKEN }} diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml new file mode 100644 index 0000000..6cb9ff0 --- /dev/null +++ b/.github/workflows/semantic-pull-request.yml @@ -0,0 +1,39 @@ +name: Semantic Pull Request + +on: + pull_request: + types: + - opened + - edited + - synchronize + - reopened + +permissions: + pull-requests: write + +jobs: + validate-pull-request: + name: Validate Pull Request title + runs-on: ubuntu-24.04 + permissions: + pull-requests: read + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} + with: + wip: true + types: | + feat + fix + build + ci + docs + refactor + perf + test + chore + release + requireScope: false + validateSingleCommit: true + validateSingleCommitMatchesPrTitle: true diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..5049538 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.0 \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..21f4cb2 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,12 @@ +included: + - Source + - Tests + - Package.swift + +analyzer_rules: + - unused_declaration + - unused_import + +line_length: + warning: 140 + error: 160 diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e791628 --- /dev/null +++ b/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Networking", + products: [ + .library( + name: "Url", + targets: ["Url"] + ), + .library( + name: "Request", + targets: ["Request"] + ) + ], + targets: [ + .target( + name: "Url" + ), + .testTarget( + name: "UrlTests", + dependencies: ["Url"] + ), + .target( + name: "Request", + dependencies: ["Url"] + ), + .testTarget( + name: "RequestTests", + dependencies: ["Request", "Url"] + ) + ], + swiftLanguageModes: [.v6] +) diff --git a/README.md b/README.md index 9cfc7cc..d1b7b0f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,108 @@ # Networking A lightweight Swift Package for typesafe URL construction and HTTP request abstraction, with strict Swift concurrency support. + +## Requirements + ++ **Swift Tools Version**: 6.0 or later ++ **Swift**: 6.0 or later ++ **Xcode**: 15.4 or later + +## Installation + +Add the package dependency to your app’s `Package.swift`: + +```swift +// swift-tools-version:6.0 +import PackageDescription + +let package = Package( + name: "YourPackage", + dependencies: [ + .package(url: "https://github.com/EmilioOjeda/Networking.git", from: "0.0.0"), + ], + targets: [ + .target( + name: "YourPackage", + dependencies: [ + .product(name: "Url", package: "Networking"), + .product(name: "Request", package: "Networking") + ] + ) + ] +) +``` + +Then import modules as needed: + +```swift +import Url +// and / or +import Request +``` + +## Modules + +### Url + +A DSL for building, validating, and manipulating `URL` instances in a type-safe, composable way. + +**Core Type**: [`Url`](Sources/Url/Url.swift) + +**Components**: + ++ `Scheme` ― Type-safe schemes (`.http`, `.https`, etc.) ++ `Host` ― Hostname wrapper ++ `Port` ― TCP/UDP port wrapper with presets (`.http`, `.https`, etc.) ++ `Path` ― Normalized URL path segments ++ `Query` & `Param` ― Query parameters via literals or a `@QueryBuilder` + +**Examples**: + +```swift +// Build "https://api.app.com:443/v1/posts?limit=20&sort=asc" +let url = Url { + Scheme(.https) + Host("api.app.com") + Port(.https) + Path("/v1/posts") + Query { + Param("limit", "20") + Param("sort", "asc") + } +} +``` + +### Request + +A result-builder–powered wrapper around `URLRequest`, assembling components into a full HTTP request. + +**Core Type**: [`Request`](Sources/Request/Request.swift) + +**Components**: + ++ `Url` ― URL path and query ++ `Method` ― HTTP methods (`.get`, `.post`, etc.) ++ `Header` & `Headers` ― Single and multiple header values with common constructors ++ `Body` ― Encodable payloads (`JSON`, `FormUrlEncoded`) + +**Examples**: + +```swift +// GET request +let getRequest = Request { + Url(string: "https://api.app.com/v1/posts") + Method(.get) + Accept("application/json") +} + +// POST request with JSON body +struct Post: Encodable { let title: String; let body: String } + +let postRequest = Request { + Url(string: "https://api.app.com/v1/posts") + Method(.post) + ContentType(.application("json")) + JSON(Post(title: "Hello", body: "World")) +} +``` diff --git a/Sources/Request/Builders/Headers+Builder.swift b/Sources/Request/Builders/Headers+Builder.swift new file mode 100755 index 0000000..1a1ccfd --- /dev/null +++ b/Sources/Request/Builders/Headers+Builder.swift @@ -0,0 +1,66 @@ +// +// Headers+Builder.swift +// Networking +// + +/// A result builder for composing HTTP headers into an ordered array of `Header`. +/// +/// Use within a `Headers` initializer block to declaratively assemble multiple `Header` values. +@resultBuilder +nonisolated public struct HeadersBuilder { + /// Flattens multiple arrays of `Header` into a single array. + /// + /// - Parameter components: One or more arrays of `Header`. + /// - Returns: A combined array of all headers in order. + nonisolated public static func buildBlock(_ components: [Header]...) -> [Header] { + components.flatMap { $0 } + } + + /// Wraps a single `Header` into an array. + /// + /// - Parameter expression: A `Header` instance. + /// - Returns: An array containing the single header. + nonisolated public static func buildExpression(_ expression: Header) -> [Header] { + [expression] + } + + /// Conditionally wraps an optional `Header` into an array. + /// + /// - Parameter expression: An optional `Header`. + /// - Returns: An array containing the header if non-nil, otherwise an empty array. + nonisolated public static func buildExpression(_ expression: Header?) -> [Header] { + expression.map { [$0] } ?? [] + } + + /// Handles the first branch of an `if-else` in the result builder. + /// + /// - Parameter component: The headers in the first branch. + /// - Returns: The provided header array. + nonisolated public static func buildEither(first component: [Header]) -> [Header] { + component + } + + /// Handles the second branch of an `if-else` in the result builder. + /// + /// - Parameter component: The headers in the second branch. + /// - Returns: The provided header array. + nonisolated public static func buildEither(second component: [Header]) -> [Header] { + component + } + + /// Flattens an array of `Header` arrays into a single header list. + /// + /// - Parameter components: An array of `Header` arrays. + /// - Returns: A flat array containing all headers. + nonisolated public static func buildArray(_ components: [[Header]]) -> [Header] { + components.flatMap { $0 } + } + + /// Handles optional content in the result builder. + /// + /// - Parameter component: An optional array of `Header`. + /// - Returns: The array if provided, or an empty array if nil. + nonisolated public static func buildOptional(_ component: [Header]?) -> [Header] { + component ?? [] + } +} diff --git a/Sources/Request/Builders/Request+Builder.swift b/Sources/Request/Builders/Request+Builder.swift new file mode 100755 index 0000000..78d17b6 --- /dev/null +++ b/Sources/Request/Builders/Request+Builder.swift @@ -0,0 +1,82 @@ +// +// Request+Builder.swift +// Networking +// + +import Foundation +import Url + +/// A marker protocol for types that can be used as components when building an HTTP request. +/// +/// Conformers represent discrete parts of a request, such as URL, method, headers, or body payloads. +public protocol RequestComponent {} + +extension Url: RequestComponent {} +extension Method: RequestComponent {} +extension Header: RequestComponent {} +extension Headers: RequestComponent {} + +/// A specialized `RequestComponent` representing an HTTP request body. +/// +/// Conformers supply raw `Data` for the body of a `URLRequest`, such as JSON or form-URL-encoded payloads. +public protocol Body: RequestComponent { + /// The raw payload data to attach as the HTTP request body. + var data: Data { get } +} + +extension JSON: Body {} +extension FormUrlEncoded: Body {} + +/// A result builder for assembling HTTP request components into an ordered array of `RequestComponent`. +/// +/// Use within a `Request` initializer block to declaratively combine `Url`, `Method`, `Header`, `Headers`, and other components. +@resultBuilder +nonisolated public struct RequestBuilder { + /// Flattens multiple arrays of `RequestComponent` into a single array. + /// + /// - Parameter components: One or more arrays of `RequestComponent`. + /// - Returns: A combined array containing all provided components in order. + nonisolated public static func buildBlock(_ components: [RequestComponent]...) -> [RequestComponent] { + components.flatMap { $0 } + } + + /// Wraps a single `RequestComponent` into an array. + /// + /// - Parameter expression: A `RequestComponent` instance. + /// - Returns: An array containing the single component. + nonisolated public static func buildExpression(_ expression: RequestComponent) -> [RequestComponent] { + [expression] + } + + /// Conditionally wraps an optional `RequestComponent` into an array. + /// + /// - Parameter expression: An optional `RequestComponent`. + /// - Returns: An array containing the component if non-nil, or an empty array otherwise. + nonisolated public static func buildExpression(_ expression: RequestComponent?) -> [RequestComponent] { + expression.map { [$0] } ?? [] + } + + /// Handles the first branch of an `if-else` in the result builder. + /// + /// - Parameter component: The components from the first branch. + /// - Returns: The provided component array. + nonisolated public static func buildEither(first component: [RequestComponent]) -> [RequestComponent] { + component + } + + /// Handles the second branch of an `if-else` in the result builder. + /// + /// - Parameter component: The components from the second branch. + /// - Returns: The provided component array. + nonisolated public static func buildEither(second component: [RequestComponent]) -> [RequestComponent] { + component + } + + /// Flattens an array of `RequestComponent` arrays into a single list. + /// + /// - Parameter components: An array of `RequestComponent` arrays. + /// - Returns: A flat array containing all components. + nonisolated public static func buildArray(_ components: [[RequestComponent]]) -> [RequestComponent] { + components.flatMap { $0 } + } +} diff --git a/Sources/Request/Components/FormUrlEncoded.swift b/Sources/Request/Components/FormUrlEncoded.swift new file mode 100755 index 0000000..1b18053 --- /dev/null +++ b/Sources/Request/Components/FormUrlEncoded.swift @@ -0,0 +1,49 @@ +// +// FormUrlEncoded.swift +// Networking +// + +import Foundation + +/// Encapsulates form-URL-encoded data payload, typically used for `application/x-www-form-urlencoded` HTTP bodies. +/// +/// Contains raw `Data` representing percent-encoded key/value pairs. +public struct FormUrlEncoded: Equatable, Hashable, Sendable { + /// The encoded form data as `Data`. + public let data: Data + + /// Initializes a `FormUrlEncoded` with raw data. + /// + /// - Parameter data: The form-URL-encoded data payload. + public init(data: Data) { + self.data = data + } + + /// Initializes by encoding a dictionary into URL-encoded form data. + /// + /// - Parameter form: A dictionary of key-value pairs to encode. + /// - Returns: An instance if the form is non-empty and encoding succeeds; otherwise `nil`. + public init?(form: [String: String]) { + if form.isEmpty { return nil } + + guard let query = urlEncodedQuery(from: form), let data = query.data(using: .utf8) else { + return nil + } + self.data = data + } +} + +/// Builds a URL-encoded query string from a dictionary of parameters. +/// +/// - Parameter params: A dictionary of string keys and values. +/// - Returns: A percent-encoded query string or `nil` if `params` is empty. +nonisolated func urlEncodedQuery(from params: [String: String]) -> String? { + if params.isEmpty { return nil } + + var urlComponents = URLComponents() + urlComponents.queryItems = params.map { key, value in + URLQueryItem(name: key, value: value) + } + + return urlComponents.query +} diff --git a/Sources/Request/Components/Header.swift b/Sources/Request/Components/Header.swift new file mode 100755 index 0000000..f38ded4 --- /dev/null +++ b/Sources/Request/Components/Header.swift @@ -0,0 +1,164 @@ +// +// Header.swift +// Networking +// + +import Foundation + +/// Represents an HTTP header field and its value. +/// +/// Combines a header name (`Header.Field`) with a string value for use in requests. +public struct Header: Equatable, Sendable { + /// The string representation of the header’s value. + public typealias Value = String + + /// The header field name (e.g., `.contentType`). + public let field: Header.Field + /// The header field’s associated string value. + public let value: Value + + /// Creates an HTTP `Header` with a specified field and value. + /// + /// - Parameters: + /// - name: The header field name. + /// - value: The string value for the header. + public init(_ name: Header.Field, _ value: Value) { + self.field = name + self.value = value + } +} + +extension Header { + /// A type-safe wrapper for HTTP header field names. + public struct Field: RawRepresentable, Equatable, Hashable, Sendable, ExpressibleByStringLiteral, ExpressibleByStringInterpolation { + public typealias RawValue = String + public typealias StringLiteralType = String + + /// The raw string value of the header field name. + public let rawValue: RawValue + + /// Initializes a `Header.Field` from a raw string. + /// + /// - Parameter rawValue: The header field name. + public init(rawValue: RawValue) { + self.rawValue = rawValue + } + + /// Initializes a `Header.Field` from a string literal. + /// + /// - Parameter value: The literal header field name. + public init(stringLiteral value: StringLiteralType) { + self.init(rawValue: value) + } + } +} + +public extension Header.Field { + /// The `Accept` header field name. + static let accept = Self("Accept") + /// The `Accept-Encoding` header field name. + static let acceptEncoding = Self("Accept-Encoding") + /// The `Accept-Language` header field name. + static let acceptLanguage = Self("Accept-Language") + /// The `Authorization` header field name. + static let authorization = Self("Authorization") + /// The `Content-Encoding` header field name. + static let contentEncoding = Self("Content-Encoding") + /// The `Content-Language` header field name. + static let contentLanguage = Self("Content-Language") + /// The `Content-Length` header field name. + static let contentLength = Self("Content-Length") + /// The `Content-Type` header field name. + static let contentType = Self("Content-Type") + /// The `User-Agent` header field name. + static let userAgent = Self("User-Agent") +} + +/// Creates an `Accept` header with the specified value. +/// +/// - Parameter value: The value for the `Accept` header. +nonisolated public func Accept(_ value: String) -> Header { + Header(.accept, value) +} + +/// Creates an `Accept-Encoding` header with the specified value. +/// +/// - Parameter value: The value for the `Accept-Encoding` header. +nonisolated public func AcceptEncoding(_ value: String) -> Header { + Header(.acceptEncoding, value) +} + +/// Creates an `Accept-Language` header with the specified value. +/// +/// - Parameter value: The value for the `Accept-Language` header. +nonisolated public func AcceptLanguage(_ value: String) -> Header { + Header(.acceptLanguage, value) +} + +/// Creates an `Authorization` header with the specified value. +/// +/// - Parameter value: The value for the `Authorization` header. +nonisolated public func Authorization(_ value: String) -> Header { + Header(.authorization, value) +} + +/// Creates a `Content-Encoding` header with the specified value. +/// +/// - Parameter value: The value for the `Content-Encoding` header. +nonisolated public func ContentEncoding(_ value: String) -> Header { + Header(.contentEncoding, value) +} + +/// Creates a `Content-Language` header with the specified value. +/// +/// - Parameter value: The value for the `Content-Language` header. +nonisolated public func ContentLanguage(_ value: String) -> Header { + Header(.contentLanguage, value) +} + +/// Creates a `Content-Length` header with the specified value. +/// +/// - Parameter value: The value for the `Content-Length` header. +nonisolated public func ContentLength(_ value: Int) -> Header { + Header(.contentLength, "\(value)") +} + +/// Creates a `Content-Type` header with the specified value. +/// +/// - Parameter value: The value for the `Content-Type` header. +nonisolated public func ContentType(_ value: String) -> Header { + Header(.contentType, value) +} + +/// Creates a `User-Agent` header with the specified value. +/// +/// - Parameter value: The value for the `User-Agent` header. +nonisolated public func UserAgent(_ value: String) -> Header { + Header(.userAgent, value) +} + +/// Common constructors for `Header.Value` to build MIME types and authorization values. +public extension Header.Value { + /// Returns an `application/` MIME header value. + /// + /// - Parameter type: The application subtype (e.g., "json"). + nonisolated static func application(_ type: String) -> Self { + "application/\(type)" + } + + /// Returns an `image/` MIME header value. + /// + /// - Parameter type: The image subtype (e.g., "png"). + nonisolated static func image(_ type: String) -> Self { + "image/\(type)" + } + + /// Returns a `Basic` authorization header value for the given credentials. + /// + /// - Parameters: + /// - username: The username. + /// - password: The password. + nonisolated static func basic(_ username: String, _ password: String) -> Self { + "Basic \(Data("\(username):\(password)".utf8).base64EncodedString())" + } +} diff --git a/Sources/Request/Components/Headers.swift b/Sources/Request/Components/Headers.swift new file mode 100755 index 0000000..e675d04 --- /dev/null +++ b/Sources/Request/Components/Headers.swift @@ -0,0 +1,22 @@ +// +// Headers.swift +// Networking +// + +/// A collection of HTTP headers built via a result builder. +/// +/// Encapsulates an ordered list of `Header` instances and returns `nil` if no headers are provided. +public struct Headers: Equatable, Sendable { + /// The ordered list of HTTP headers. + public let headers: [Header] + + /// Creates a `Headers` collection using a `@HeadersBuilder` result builder. + /// + /// - Parameter builder: A builder block returning an array of `Header`. + /// - Returns: A `Headers` if at least one `Header` is provided; otherwise `nil`. + public init?(@HeadersBuilder _ builder: () -> [Header]) { + let headers = builder() + if headers.isEmpty { return nil } + self.headers = headers + } +} diff --git a/Sources/Request/Components/JSON.swift b/Sources/Request/Components/JSON.swift new file mode 100755 index 0000000..2df653f --- /dev/null +++ b/Sources/Request/Components/JSON.swift @@ -0,0 +1,34 @@ +// +// JSON.swift +// Networking +// + +import Foundation + +/// A wrapper for JSON-encoded data, conforming to `Encodable` output. +/// +/// Encapsulates raw `Data` produced by encoding a Swift `Encodable` value. +public struct JSON: Equatable, Hashable, Sendable { + /// The raw JSON data payload. + public let data: Data + + /// Initializes a `JSON` instance from existing JSON data. + /// + /// - Parameter data: Pre-encoded JSON data. + public init(data: Data) { + self.data = data + } + + /// Attempts to initialize by encoding an `Encodable` value into JSON. + /// + /// - Parameters: + /// - encodable: The value to encode into JSON. + /// - encoder: The `JSONEncoder` to use (default is a new `JSONEncoder`). + /// - Returns: A `JSON` instance if encoding succeeds; otherwise `nil`. + public init?(_ encodable: some Encodable, using encoder: JSONEncoder = JSONEncoder()) { + guard let data = try? encoder.encode(encodable) else { + return nil + } + self.data = data + } +} diff --git a/Sources/Request/Components/Method.swift b/Sources/Request/Components/Method.swift new file mode 100755 index 0000000..aa9db86 --- /dev/null +++ b/Sources/Request/Components/Method.swift @@ -0,0 +1,44 @@ +// +// Method.swift +// Networking +// + +/// A type-safe wrapper for HTTP methods (e.g., GET, POST, and so on). +public struct Method: Equatable, Hashable, Sendable { + /// The encapsulated HTTP method value. + public let value: Method.Value + + /// The raw string representation of the HTTP method (e.g., "GET", "POST", and so on). + public var rawValue: String { value.rawValue } + + /// Initializes a `Method` from a `Method.Value`. + /// + /// - Parameter value: The HTTP method value to wrap. + public init(_ value: Method.Value) { + self.value = value + } +} + +extension Method { + /// Defines the set of supported HTTP methods as raw string values. + public enum Value: String, Sendable { + /// GET method. + case get = "GET" + /// HEAD method. + case head = "HEAD" + /// POST method. + case post = "POST" + /// PUT method. + case put = "PUT" + /// DELETE method. + case delete = "DELETE" + /// CONNECT method. + case connect = "CONNECT" + /// OPTIONS method. + case options = "OPTIONS" + /// TRACE method. + case trace = "TRACE" + /// PATCH method. + case patch = "PATCH" + } +} diff --git a/Sources/Request/Request.swift b/Sources/Request/Request.swift new file mode 100755 index 0000000..953b197 --- /dev/null +++ b/Sources/Request/Request.swift @@ -0,0 +1,76 @@ +// +// Request.swift +// Networking +// + +import Foundation +import Url + +/// A HTTP request abstraction built via a result builder of request components. +/// +/// The `Request` struct uses the `@RequestBuilder` attribute to combine components like `Url`, `Method`, `Headers`, and `Body` into a `URLRequest`. +/// Initialization returns `nil` if a valid `Url` component is not provided. +public struct Request { + let urlRequest: URLRequest + + /// The constructed `URLRequest` representing this request. + public var asURLRequest: URLRequest { urlRequest } + + /// Creates a `Request` by combining provided `RequestComponent`s. + /// + /// - Parameter builder: A result builder block returning an array of `RequestComponent`s such as `Url`, `Method`, `Headers`, and `Body`. + /// - Returns: An initialized `Request` if a valid `Url` component is present; otherwise `nil`. + public init?(@RequestBuilder _ builder: () -> [RequestComponent]) { + let builtComponents = builder() + + guard let url = Request.url(in: builtComponents) else { + return nil + } + var urlRequest = URLRequest(url: url.asURL) + + if let method = Request.method(in: builtComponents) { + urlRequest.httpMethod = method.rawValue + } + + if let body = Request.body(in: builtComponents) { + urlRequest.httpBody = body.data + } + + if let headers = Request.headers(in: builtComponents) { + for header in headers { + urlRequest.addValue(header.value, forHTTPHeaderField: header.field.rawValue) + } + } + + self.urlRequest = urlRequest + } +} + +private extension Request { + nonisolated static func url(in components: [RequestComponent]) -> Url? { + components.first { $0 is Url } as? Url + } + + nonisolated static func method(in components: [RequestComponent]) -> Method? { + components.first { $0 is Method } as? Method + } + + nonisolated static func body(in components: [RequestComponent]) -> Body? { + if let json = components.first(where: { $0 is JSON }) as? JSON { + return json + } else if let formUrlEncoded = components.first(where: { $0 is FormUrlEncoded }) as? FormUrlEncoded { + return formUrlEncoded + } + return nil + } + + nonisolated static func headers(in components: [RequestComponent]) -> [Header]? { + Optional(components.compactMap({ $0 as? Headers }).flatMap(\.headers) + components.compactMap({ $0 as? Header })) + .flatMap { headers in + if headers.isEmpty { + return nil + } + return headers + } + } +} diff --git a/Sources/Url/Builders/Query+Builder.swift b/Sources/Url/Builders/Query+Builder.swift new file mode 100755 index 0000000..97fabf0 --- /dev/null +++ b/Sources/Url/Builders/Query+Builder.swift @@ -0,0 +1,58 @@ +// +// Query+Builder.swift +// Networking +// + +/// A result builder for composing URL query parameters into an ordered array of `Param`. +/// +/// Use within a `Query` initializer block to declaratively assemble multiple `Param` values. +@resultBuilder +nonisolated public struct QueryBuilder { + /// Flattens multiple arrays of `Param` into a single array. + /// + /// - Parameter components: One or more arrays of `Param`. + /// - Returns: A combined array of all parameters in order. + nonisolated public static func buildBlock(_ components: [Param]...) -> [Param] { + components.flatMap { $0 } + } + + /// Wraps a single `Param` into an array. + /// + /// - Parameter expression: A `Param` instance. + /// - Returns: An array containing the single parameter. + nonisolated public static func buildExpression(_ expression: Param) -> [Param] { + [expression] + } + + /// Handles the first branch of an `if-else` in the result builder. + /// + /// - Parameter component: The parameters in the first branch. + /// - Returns: The provided parameter array. + nonisolated public static func buildEither(first component: [Param]) -> [Param] { + component + } + + /// Handles the second branch of an `if-else` in the result builder. + /// + /// - Parameter component: The parameters in the second branch. + /// - Returns: The provided parameter array. + nonisolated public static func buildEither(second component: [Param]) -> [Param] { + component + } + + /// Flattens an array of parameter arrays into a single parameter list. + /// + /// - Parameter components: An array of `Param` arrays. + /// - Returns: A flat array containing all parameters. + nonisolated public static func buildArray(_ components: [[Param]]) -> [Param] { + components.flatMap { $0 } + } + + /// Handles optional content in the result builder. + /// + /// - Parameter component: An optional array of `Param`. + /// - Returns: The array if provided, or an empty array if nil. + nonisolated public static func buildOptional(_ component: [Param]?) -> [Param] { + component ?? [] + } +} diff --git a/Sources/Url/Builders/Url+Builder.swift b/Sources/Url/Builders/Url+Builder.swift new file mode 100755 index 0000000..18e6253 --- /dev/null +++ b/Sources/Url/Builders/Url+Builder.swift @@ -0,0 +1,69 @@ +// +// UrlBuilder.swift +// Networking +// + +/// A marker protocol for types that can serve as components in URL construction using `@UrlBuilder`. +/// +/// Conformers represent discrete parts of a URL, such as `Scheme`, `Host`, `Path`, `Port`, and `Query`. +public protocol UrlComponent {} + +extension Scheme: UrlComponent {} +extension Host: UrlComponent {} +extension Path: UrlComponent {} +extension Port: UrlComponent {} +extension Query: UrlComponent {} + +/// A result builder for composing URL components into an ordered array of `UrlComponent`. +/// +/// Use within a `Url` initializer block to declaratively assemble parts like `Scheme`, `Host`, `Path`, `Port`, and `Query`. +@resultBuilder +nonisolated public struct UrlBuilder { + /// Flattens multiple arrays of URL components into a single array. + /// + /// - Parameter components: One or more arrays of `UrlComponent`. + /// - Returns: A single array containing all provided components in order. + nonisolated public static func buildBlock(_ components: [UrlComponent]...) -> [UrlComponent] { + components.flatMap { $0 } + } + + /// Wraps a single URL component into an array. + /// + /// - Parameter expression: A `UrlComponent` instance. + /// - Returns: An array containing the single component. + nonisolated public static func buildExpression(_ expression: UrlComponent) -> [UrlComponent] { + [expression] + } + + /// Conditionally wraps an optional URL component into an array. + /// + /// - Parameter expression: An optional `UrlComponent`. + /// - Returns: An array containing the component if non-nil, otherwise an empty array. + nonisolated public static func buildExpression(_ expression: UrlComponent?) -> [UrlComponent] { + expression.map { [$0] } ?? [] + } + + /// Handles the first branch of an `if-else` in the result builder. + /// + /// - Parameter component: The components in the first branch. + /// - Returns: The provided component array. + nonisolated public static func buildEither(first component: [UrlComponent]) -> [UrlComponent] { + component + } + + /// Handles the second branch of an `if-else` in the result builder. + /// + /// - Parameter component: The components in the second branch. + /// - Returns: The provided component array. + nonisolated public static func buildEither(second component: [UrlComponent]) -> [UrlComponent] { + component + } + + /// Handles optional content in the result builder. + /// + /// - Parameter component: An optional array of `UrlComponent`. + /// - Returns: The array if provided, or an empty array if nil. + nonisolated public static func buildOptional(_ component: [UrlComponent]?) -> [UrlComponent] { + component ?? [] + } +} diff --git a/Sources/Url/Components/Host.swift b/Sources/Url/Components/Host.swift new file mode 100755 index 0000000..c185c26 --- /dev/null +++ b/Sources/Url/Components/Host.swift @@ -0,0 +1,17 @@ +// +// Host.swift +// Networking +// + +/// Represents the host component of a URL, wrapping a hostname string. +public struct Host: Equatable, Hashable, Sendable { + /// The hostname string (e.g., "api.app.com"). + public let value: String + + /// Creates a `Host` instance from a hostname string. + /// + /// - Parameter value: The hostname (e.g., "api.app.com"). + public init(_ value: String) { + self.value = value + } +} diff --git a/Sources/Url/Components/Param.swift b/Sources/Url/Components/Param.swift new file mode 100755 index 0000000..bcf6d41 --- /dev/null +++ b/Sources/Url/Components/Param.swift @@ -0,0 +1,59 @@ +// +// Param.swift +// Networking +// + +/// Represents a URL query parameter with a name and optional value. +/// +/// `Param` is used to build URL query items for HTTP requests, encapsulating the parameter name and its string value. +public struct Param: Equatable, Sendable { + /// The string value of the parameter, or `nil` for parameters without a value. + public typealias Value = String? + + /// The name of the query parameter. + public let name: Param.Name + /// The optional value associated with the parameter name. + public let value: Value + + /// Creates a parameter with a name and no value. + /// + /// - Parameter name: The name of the query parameter. + public init(_ name: Param.Name) { + self.init(name, nil) + } + + /// Creates a parameter with a name and an optional value. + /// + /// - Parameters: + /// - name: The name of the query parameter. + /// - value: The string value, or `nil` if absent. + public init(_ name: Param.Name, _ value: Value) { + self.name = name + self.value = value + } +} + +extension Param { + /// A type-safe wrapper for parameter names, allowing string literals and interpolation. + public struct Name: RawRepresentable, Equatable, Hashable, Sendable, ExpressibleByStringLiteral, ExpressibleByStringInterpolation { + public typealias RawValue = String + public typealias StringLiteralType = String + + /// The raw string value of the parameter name. + public let rawValue: RawValue + + /// Initializes a `Param.Name` from a raw string value. + /// + /// - Parameter rawValue: The raw string to represent as a parameter name. + public init(rawValue: RawValue) { + self.rawValue = rawValue + } + + /// Initializes a `Param.Name` from a string literal. + /// + /// - Parameter value: The string literal representing the parameter name. + public init(stringLiteral value: StringLiteralType) { + self.init(rawValue: value) + } + } +} diff --git a/Sources/Url/Components/Path.swift b/Sources/Url/Components/Path.swift new file mode 100755 index 0000000..9221396 --- /dev/null +++ b/Sources/Url/Components/Path.swift @@ -0,0 +1,53 @@ +// +// Path.swift +// Networking +// + +import Foundation + +/// A URL path component type that normalizes and sanitizes path strings. +/// +/// Ensures the path is trimmed of whitespace, collapses redundant slashes, always starts with a single leading slash, and has no trailing slash. +public struct Path: Equatable, Hashable, Sendable, ExpressibleByStringLiteral, ExpressibleByStringInterpolation { + public typealias Value = String + public typealias StringLiteralType = String + + /// The sanitized path string value. + public let value: Value + + /// Creates a `Path` by sanitizing the provided string. + /// + /// - Parameter value: The raw path string to normalize. + public init(_ value: Value) { + self.value = sanitized(path: value) + } + + /// Creates a `Path` from a string literal, applying the same sanitation rules. + /// + /// - Parameter value: The path string literal to normalize. + public init(stringLiteral value: StringLiteralType) { + self.init(value) + } +} + +/// Internal helper to trim whitespace, collapse duplicate slashes, and enforce leading/trailing slash rules on the path string. +nonisolated private func sanitized(path: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: "/") + .map { component in + component.trimmingCharacters(in: .whitespacesAndNewlines) + } + .joined(separator: "/") + + var formatted = trimmed + + if formatted.hasPrefix("/") { + formatted = formatted.replacingOccurrences(of: "^(\\/)+", with: "/", options: [.regularExpression]) + } else { formatted = "/" + formatted } + + if formatted.hasSuffix("/") { + formatted = formatted.replacingOccurrences(of: "(\\/)+$", with: "", options: [.regularExpression]) + } + + return formatted +} diff --git a/Sources/Url/Components/Port.swift b/Sources/Url/Components/Port.swift new file mode 100755 index 0000000..32a09c6 --- /dev/null +++ b/Sources/Url/Components/Port.swift @@ -0,0 +1,70 @@ +// +// Port.swift +// Networking +// + +/// A type-safe wrapper around a network port number, used for URL and network configurations. +/// +/// Represents a TCP/UDP port, conforming to `RawRepresentable` (via `RawValue`). +public struct Port: RawRepresentable, Equatable, Hashable, Sendable { + /// The underlying raw integer type for the port. + public typealias RawValue = Port.Value.RawValue + + /// The encapsulated port value. + public let value: Port.Value + + /// Initializes a `Port` from a `Port.Value`. + /// + /// - Parameter value: The wrapped port value. + public init(_ value: Port.Value) { + self.value = value + } + + /// Initializes a `Port` from a raw integer value. + /// + /// - Parameter rawValue: The raw integer representing the port. + public init(rawValue: RawValue) { + self.init(Port.Value(rawValue: rawValue)) + } + + /// The raw integer value of this `Port`. + public var rawValue: RawValue { value.rawValue } +} + +extension Port { + /// The raw underlying integer type for `Port`, supporting integer literals. + public struct Value: RawRepresentable, Equatable, Hashable, Sendable, ExpressibleByIntegerLiteral { + public typealias RawValue = UInt + public typealias IntegerLiteralType = UInt + + /// The integer value representing the port number. + public let rawValue: RawValue + + /// Initializes `Value` from a raw integer port number. + /// + /// - Parameter rawValue: The integer value for the port. + public init(rawValue: RawValue) { + self.rawValue = rawValue + } + + /// Initializes `Value` from an integer literal. + /// + /// - Parameter value: The integer literal representing the port number. + public init(integerLiteral value: IntegerLiteralType) { + self.init(rawValue: value) + } + } +} + +public extension Port.Value { + /// Default FTP port (21). + static let ftp = Self(21) + /// Default SSH port (22). + static let ssh = Self(22) + /// Default HTTP port (80). + static let http = Self(80) + /// Default HTTPS port (443). + static let https = Self(443) + /// Common local development port (8080). + static let localhost = Self(8080) +} diff --git a/Sources/Url/Components/Query.swift b/Sources/Url/Components/Query.swift new file mode 100755 index 0000000..877aa29 --- /dev/null +++ b/Sources/Url/Components/Query.swift @@ -0,0 +1,66 @@ +// +// Query.swift +// Networking +// + +/// A collection of URL query parameters, represented as an array of `Param`. +/// +/// Supports initialization from: +/// - Arrays of `Param`. +/// - Variadic `Param` parameters. +/// - Array literals. +/// - Dictionary literals. +/// - A custom `@QueryBuilder` result builder. +/// +/// When initialized via the builder, returns `nil` if no parameters are provided. +public struct Query: Equatable, Sendable, ExpressibleByArrayLiteral, ExpressibleByDictionaryLiteral { + public typealias Key = Param.Name + public typealias Value = Param.Value + public typealias ArrayLiteralElement = Param + + /// The ordered list of query parameters. + public let params: [Param] + + /// Creates a `Query` from an array of `Param` values. + /// + /// - Parameter params: An array of `Param` instances. + public init(_ params: [Param]) { + self.params = params + } + + /// Creates a `Query` from a variadic list of `Param` values. + /// + /// - Parameter params: A variadic list of `Param` instances. + public init(params: Param...) { + self.init(params) + } + + /// Creates a `Query` from an array literal of `Param` values. + /// + /// Supports syntax like: + /// ```swift + /// let query: Query = [Param("foo", "bar"), Param("baz", nil)] + /// ``` + public init(arrayLiteral elements: ArrayLiteralElement...) { + params = elements + } + + /// Creates a `Query` from a dictionary literal of parameter names and values. + /// + /// - Parameter elements: A variadic list of key–value pairs `(Param.Name, Param.Value)`. + public init(dictionaryLiteral elements: (Key, Value)...) { + params = elements.map { key, value in + Param(key, value) + } + } + + /// Creates a `Query` using a `@QueryBuilder` result builder. + /// + /// - Parameter builder: A builder block returning an array of `Param`. + /// - Returns: A `Query` if at least one `Param` is provided; otherwise `nil`. + public init?(@QueryBuilder _ builder: () -> [Param]) { + let params = builder() + if params.isEmpty { return nil } + self.params = params + } +} diff --git a/Sources/Url/Components/Scheme.swift b/Sources/Url/Components/Scheme.swift new file mode 100755 index 0000000..ec8fdde --- /dev/null +++ b/Sources/Url/Components/Scheme.swift @@ -0,0 +1,76 @@ +// +// Scheme.swift +// Networking +// + +/// Represents the URL scheme component (e.g., "https", "http"), wrapped as a type-safe value. +/// +/// Conforms to `RawRepresentable` (via `RawValue` = `String`). +public struct Scheme: RawRepresentable, Equatable, Hashable, Sendable { + /// The underlying raw string type for the scheme. + public typealias RawValue = Scheme.Value.RawValue + + /// The wrapped scheme value. + public let value: Scheme.Value + + /// Initializes a `Scheme` with a `Value`. + /// + /// - Parameter value: The scheme value to wrap. + public init(_ value: Scheme.Value) { + self.value = value + } + + /// Initializes a `Scheme` from a raw string value. + /// + /// - Parameter rawValue: The raw string representing the scheme. + public init(rawValue: RawValue) { + self.init(Scheme.Value(rawValue: rawValue)) + } + + /// The raw string value of this `Scheme`. + public var rawValue: RawValue { value.rawValue } +} + +extension Scheme { + /// The raw string wrapper for a URL scheme, supporting string literals and interpolation. + public struct Value: RawRepresentable, Equatable, Hashable, Sendable, ExpressibleByStringLiteral, ExpressibleByStringInterpolation { + public typealias RawValue = String + public typealias StringLiteralType = String + + /// The raw string representing the scheme. + public let rawValue: RawValue + + /// Initializes a `Value` from a raw string. + /// + /// - Parameter rawValue: The raw string value. + public init(rawValue: RawValue) { + self.rawValue = rawValue + } + + /// Initializes a `Value` from a string literal. + /// + /// - Parameter value: The string literal to use as the scheme. + public init(stringLiteral value: StringLiteralType) { + self.init(rawValue: value) + } + } +} + +public extension Scheme.Value { + /// Default telephone URL scheme ("tel"). + static let tel = Self("tel") + /// Default email URL scheme ("mailto"). + static let mailto = Self("mailto") + /// Default HTTP URL scheme ("http"). + static let http = Self("http") + /// Default HTTPS URL scheme ("https"). + static let https = Self("https") + /// Default file URL scheme ("file"). + static let file = Self("file") + /// Default FTP URL scheme ("ftp"). + static let ftp = Self("ftp") + /// Default SSH URL scheme ("ssh"). + static let ssh = Self("ssh") + /// Localhost URL scheme ("localhost"). + static let localhost = Self("localhost") +} diff --git a/Sources/Url/Url.swift b/Sources/Url/Url.swift new file mode 100755 index 0000000..f2da537 --- /dev/null +++ b/Sources/Url/Url.swift @@ -0,0 +1,104 @@ +// +// Url.swift +// Networking +// + +import Foundation + +/// A value type that wraps Foundation's `URL`, providing typesafe URL construction and validation. +/// +/// This struct supports URL components like scheme, host, port, path, and query parameters. +public struct Url: Equatable, Hashable, Sendable { + let url: URL + + /// Returns the underlying `URL` instance. + public var asURL: URL { url } + + /// Initializes a `Url` from an existing `URL`. + /// + /// - Parameter url: A valid `URL` instance. + public init(url: URL) { + self.url = url + } + + /// Initializes a `Url` by parsing a string. + /// + /// Trims whitespace and validates the non-empty, well-formed URL string. + /// + /// - Parameter string: The URL string to parse. + /// - Returns: A `Url` if parsing succeeds; otherwise `nil`. + public init?(string: String) { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + + guard trimmed.isEmpty == false, let url = URL(string: trimmed), url.absoluteString.isEmpty == false else { + return nil + } + self.url = url + } + + /// Initializes a `Url` by composing `UrlComponent`s with the `@UrlBuilder` result builder. + /// + /// - Parameter builder: A builder block returning an array of `UrlComponent`s (e.g., `Scheme`, `Host`, `Port`, `Path`, `Query`). + /// - Returns: A `Url` if the composed components form a valid URL; otherwise `nil`. + public init?(@UrlBuilder _ builder: () -> [UrlComponent]) { + var urlComponents = URLComponents() + let builtComponents = builder() + + if let scheme = Url.scheme(in: builtComponents) { + urlComponents.scheme = scheme.rawValue + } + + if let host = Url.host(in: builtComponents) { + urlComponents.host = host.value + } + + if let port = Url.port(in: builtComponents) { + urlComponents.port = Int(port.rawValue) + } + + if let path = Url.path(in: builtComponents) { + urlComponents.path = path.value + } + + if let query = Url.query(in: builtComponents) { + urlComponents.queryItems = query.params.map(urlQueryItem(from:)) + } + + guard let url = urlComponents.url, url.absoluteString.isEmpty == false else { + return nil + } + self.url = url + } +} + +private extension Url { + nonisolated static func scheme(in components: [UrlComponent]) -> Scheme? { + components.first { $0 is Scheme } as? Scheme + } + + nonisolated static func host(in components: [UrlComponent]) -> Host? { + components.first { $0 is Host } as? Host + } + + nonisolated static func port(in components: [UrlComponent]) -> Port? { + components.first { $0 is Port } as? Port + } + + nonisolated static func path(in components: [UrlComponent]) -> Path? { + components.first { $0 is Path } as? Path + } + + nonisolated static func query(in components: [UrlComponent]) -> Query? { + Optional(components.compactMap({ $0 as? Query }).flatMap(\.params)) + .flatMap { params in + if params.isEmpty { + return nil + } + return Query(params) + } + } +} + +nonisolated private func urlQueryItem(from param: Param) -> URLQueryItem { + URLQueryItem(name: param.name.rawValue, value: param.value) +} diff --git a/Tests/RequestTests/UnitTests.swift b/Tests/RequestTests/UnitTests.swift new file mode 100755 index 0000000..76e0ffb --- /dev/null +++ b/Tests/RequestTests/UnitTests.swift @@ -0,0 +1,133 @@ +// +// UnitTests.swift +// Networking +// + +import Foundation +@testable import Request +import Testing +import Url + +@Suite +struct UnitTests { + @Test("`Method` type") + func testMethodType() async throws { + #expect(Method(.get).rawValue == "GET") + #expect(Method(.head).rawValue == "HEAD") + #expect(Method(.post).rawValue == "POST") + #expect(Method(.put).rawValue == "PUT") + #expect(Method(.delete).rawValue == "DELETE") + #expect(Method(.connect).rawValue == "CONNECT") + #expect(Method(.options).rawValue == "OPTIONS") + #expect(Method(.trace).rawValue == "TRACE") + #expect(Method(.patch).rawValue == "PATCH") + } + + @Test("`Header` type") + func testHeaderType() async throws { + #expect(Header("field", "value") == Header(Header.Field(rawValue: "field"), "value")) + } + + @Test("`Header` - commonly used request headers") + func testCommonlyUsedRequestHeaders() async throws { + #expect(Accept("*/*") == Header(.accept, "*/*")) + #expect(Accept(.image("png")) == Header(.accept, "image/png")) + #expect(AcceptEncoding("*") == Header(.acceptEncoding, "*")) + #expect(AcceptLanguage("en-US") == Header(.acceptLanguage, "en-US")) + #expect(Authorization(.basic("Account", "P4$$w0rd")) == Header(.authorization, "Basic QWNjb3VudDpQNCQkdzByZA==")) + #expect(ContentEncoding("gzip") == Header(.contentEncoding, "gzip")) + #expect(ContentLanguage("en-US") == Header(.contentLanguage, "en-US")) + #expect(ContentLength(1024) == Header(.contentLength, "1024")) + #expect(ContentType(.application("json")) == Header(.contentType, "application/json")) + #expect(UserAgent("App/1.0") == Header(.userAgent, "App/1.0")) + } + + @Test("`Headers` type") + func testHeadersType() async throws { + #expect(Headers {} == nil) + #expect( + try #require( + Headers { + Header("Field", "Value") + } + ).headers == [Header("Field", "Value")] + ) + } + + @Test("`JSON` type") + func testJSONType() async throws { + let encoder = JSONEncoder() + let encoded = try encoder.encode(["iOS"]) + #expect(JSON(data: encoded) == JSON(["iOS"], using: encoder)) + } + + @Test("`FormUrlEncoded` type") + func testFormUrlEncodedType() async throws { + let encoded = try #require(urlEncodedQuery(from: ["platform": "iOS"])?.data(using: .utf8)) + #expect(FormUrlEncoded(data: encoded) == FormUrlEncoded(form: ["platform": "iOS"])) + } + + @Test("`Request` type") + func testRequestType() async throws { + let encoder = JSONEncoder() + let encodable = ["iOS"] + let expectedURL = try #require(URL(string: "https://app.com/api/v1/platforms")) + let expectedData = try encoder.encode(encodable) + + let urlRequest = try #require( + Request { + Method(.post) + Url { + Scheme(.https) + Host("app.com") + Path("/api/v1/platforms") + } + + JSON(encodable, using: encoder) + + for index in 0 ... 1 { + Header("X-Outer-Index-\(index)", "Value-\(index)") + } + + if true { + Header("X-Outer-Either-First", "Value-First") + } else { Header("Error", "") } + + if false { Header("Error", "") } else { + Header("X-Outer-Either-Second", "Value-Second") + } + + Headers { + for index in 0 ... 1 { + Header("X-Inner-Index-\(index)", "Value-\(index)") + } + + if true { + Header("X-Inner-Either-First", "Value-First") + } else { Header("Error", "") } + + if false { Header("Error", "") } else { + Header("X-Inner-Either-Second", "Value-Second") + } + + Optional(Header("X-Optional", "Value-Optional")) + } + } + ).urlRequest + + #expect(urlRequest.url == expectedURL) + #expect(urlRequest.httpMethod == "POST") + #expect(urlRequest.httpBody == expectedData) + let headers = try #require(urlRequest.allHTTPHeaderFields) + #expect(headers.count == 9) + #expect(headers["X-Outer-Index-0"] == "Value-0") + #expect(headers["X-Outer-Index-1"] == "Value-1") + #expect(headers["X-Outer-Either-First"] == "Value-First") + #expect(headers["X-Outer-Either-Second"] == "Value-Second") + #expect(headers["X-Inner-Index-0"] == "Value-0") + #expect(headers["X-Inner-Index-1"] == "Value-1") + #expect(headers["X-Inner-Either-First"] == "Value-First") + #expect(headers["X-Inner-Either-Second"] == "Value-Second") + #expect(headers["X-Optional"] == "Value-Optional") + } +} diff --git a/Tests/UrlTests/UnitTests.swift b/Tests/UrlTests/UnitTests.swift new file mode 100755 index 0000000..811a06e --- /dev/null +++ b/Tests/UrlTests/UnitTests.swift @@ -0,0 +1,128 @@ +// +// UnitTests.swift +// Networking +// + +import Foundation +import Testing +import Url + +@Suite +struct UnitTests { + @Test("`Scheme` type") + func testSchemeType() async throws { + #expect(Scheme("app") == Scheme(rawValue: "app")) + } + + @Test("`Host` type") + func testHostType() async throws { + #expect(Host("app.com").value == "app.com") + } + + @Test("`Port` type") + func testPortType() async throws { + #expect(Port(.ftp) == Port(rawValue: 21)) + #expect(Port(.ssh) == Port(rawValue: 22)) + #expect(Port(.http) == Port(rawValue: 80)) + #expect(Port(.https) == Port(rawValue: 443)) + #expect(Port(.localhost) == Port(rawValue: 8080)) + } + + @Test("`Path` type") + func testPathType() async throws { + #expect(Path("path") == "/path") + #expect(Path("/path") == "/path") + #expect(Path("/path/") == "/path") + #expect(Path("//path/") == "/path") + #expect(Path("//path//") == "/path") + } + + @Test("`Param` type") + func tesParamType() async throws { + let nameOnlyParam = Param("name") + #expect(nameOnlyParam.name == "name") + #expect(nameOnlyParam.value == nil) + let nameAndValueParam = Param("name", "value") + #expect(nameAndValueParam.name == "name") + #expect(nameAndValueParam.value == "value") + } + + @Test("`Query` type") + func testQueryType() async throws { + #expect(Query(params: Param("name")) == [Param("name")]) + #expect(Query(params: Param("name", "value")) == ["name": "value"]) + #expect(Query {} == nil) + + let query = try #require( + Query { + Param("name-0") + if true { + Param("name-1") + } else { Param("") } + + if false { Param("") } else { + Param("name-2") + } + + for index in (3 ... 4) { + Param("name-\(index)") + } + + if Optional(nil) != nil { Param("") } + if let five = Optional("5") { + Param("name-\(five)") + } + } + ) + #expect(query.params.count == 6) + #expect(query.params[0] == Param("name-0")) + #expect(query.params[1] == Param("name-1")) + #expect(query.params[2] == Param("name-2")) + #expect(query.params[3] == Param("name-3")) + #expect(query.params[4] == Param("name-4")) + #expect(query.params[5] == Param("name-5")) + } + + @Test("`Url` type") + func testUrlType() async throws { + let urlString = "https://app.com:443/path?name=value" + let url = try #require(URL(string: urlString)) + #expect(Url(url: url) == Url(string: urlString)) + #expect(Url {} == nil) + #expect(Url(string: "") == nil) + #expect( + try #require( + Url { + Scheme(.https) + Host("app.com") + Port(.https) + Path("/path") + Query { + Param("name", "value") + } + } + ).asURL == url + ) + #expect( + try #require( + Url { + if true { + Scheme(.https) + } else { Scheme("dummy") } + + if false { Host("dummy.com") } else { + Host("app.com") + } + + if Optional(nil) != nil { Port(0) } + if let port: UInt = Optional(443) { + Port(rawValue: port) + } + + Optional(Path("/path")) + Query {} + } + ).asURL.absoluteString == "https://app.com:443/path" + ) + } +}