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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/build-package.yml
Original file line number Diff line number Diff line change
@@ -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
64 changes: 64 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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
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
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 }}
39 changes: 39 additions & 0 deletions .github/workflows/semantic-pull-request.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .swift-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
6.0
12 changes: 12 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
included:
- Source
- Tests
- Package.swift

analyzer_rules:
- unused_declaration
- unused_import

line_length:
warning: 140
error: 160
36 changes: 36 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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]
)
105 changes: 105 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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: "<released-version>"),
],
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"))
}
```
66 changes: 66 additions & 0 deletions Sources/Request/Builders/Headers+Builder.swift
Original file line number Diff line number Diff line change
@@ -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 ?? []
}
}
Loading