Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ Declarative and generic REST API framework using Codable.
With standard implementation using URLSesssion and JSON encoder/decoder.
Easily extensible for your asynchronous framework or networking stack.

## Documentation

For detailed API documentation, see the inline documentation in the source code or use Xcode's Quick Help (⌥⌘?) to view comprehensive documentation for each component.

Key components:
- **Logging**: ``LogEntry``, ``LoggerConfiguration``, ``LogPrivacy`` - Automatic network logging with OSLog
- **Analytics**: ``AnalyticsProtocol``, ``AnalyticEntry``, ``AnalyticsConfiguration`` - Privacy-aware analytics tracking

## Installation

When using Swift package manager install using Xcode 11+
Expand Down Expand Up @@ -63,6 +71,15 @@ are separated in various protocols for convenience.

![Endpoint types](Sources/FTAPIKit/Documentation.docc/Resources/Endpoints.svg)

## Logging & Analytics

FTAPIKit includes comprehensive logging and analytics capabilities:

- **Logging**: Automatic network request/response logging using native `OSLog` with configurable privacy levels
- **Analytics**: Privacy-aware analytics tracking with automatic data masking for sensitive information

Both systems work together seamlessly and can be used independently or in combination.

## Usage

### Defining web service (server)
Expand Down Expand Up @@ -137,9 +154,9 @@ let server = HTTPBinServer()
let endpoint = UpdateUserEndpoint(request: user)
server.call(response: endpoint) { result in
switch result {
case .success(let updatedUser):
case let .success(updatedUser):
...
case .failure(let error):
case let .failure(error):
...
}
}
Expand Down
79 changes: 79 additions & 0 deletions Sources/FTAPIKit/Analytics/AnalyticEntry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import Foundation

/// Data structure for analytics tracking.
///
/// This struct contains network activity data that has been privacy-masked based on
/// the configured ``AnalyticsConfiguration``. It uses ``EntryType`` with associated values
/// to provide type-safe access to basic network information without optionals.
///
/// - Note: This struct is used by ``AnalyticsProtocol`` implementations for tracking
/// network activity. For logging purposes, use ``LogEntry`` instead.
public struct AnalyticEntry {
public let type: EntryType
public let headers: [String: String]?
public let body: Data?
public let timestamp: Date
public let duration: TimeInterval?
public let requestId: String

public init(
type: EntryType,
headers: [String: String]? = nil,
body: Data? = nil,
timestamp: Date = Date(),
duration: TimeInterval? = nil,
requestId: String = UUID().uuidString,
configuration: AnalyticsConfiguration = AnalyticsConfiguration.default
) {
// Create masked type with masked URL
let maskedType: EntryType
switch type {
case let .request(method, url):
maskedType = .request(method: method, url: configuration.maskUrl(url) ?? url)
case let .response(method, url, statusCode):
maskedType = .response(method: method, url: configuration.maskUrl(url) ?? url, statusCode: statusCode)
case let .error(method, url, error):
maskedType = .error(method: method, url: configuration.maskUrl(url) ?? url, error: error)
}

self.type = maskedType
self.headers = configuration.maskHeaders(headers)
self.body = configuration.maskBody(body)
self.timestamp = timestamp
self.duration = duration
self.requestId = requestId
}

/// Convenience computed properties for accessing associated values
public var method: String {
switch type {
case let .request(method, _), let .response(method, _, _), let .error(method, _, _):
method
}
}

public var url: String {
switch type {
case let .request(_, url), let .response(_, url, _), let .error(_, url, _):
url
}
}

public var statusCode: Int? {
switch type {
case let .response(_, _, statusCode):
statusCode
case .request, .error:
nil
}
}

public var error: String? {
switch type {
case let .error(_, _, error):
error
case .request, .response:
nil
}
}
}
138 changes: 138 additions & 0 deletions Sources/FTAPIKit/Analytics/AnalyticsConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import Foundation

/// Configuration for analytics functionality.
///
/// This struct defines the privacy level and exceptions for masking sensitive data
/// in analytics. It allows you to specify which headers, URL query parameters,
/// and body parameters should not be masked.
public struct AnalyticsConfiguration {
private let privacy: AnalyticsPrivacy
private let unmaskedHeaders: Set<String>
private let unmaskedUrlQueries: Set<String>
private let unmaskedBodyParams: Set<String>

/// Initializes a new analytics configuration.
///
/// - Parameters:
/// - privacy: The privacy level for data masking.
/// - unmaskedHeaders: A set of header keys that should not be masked.
/// - unmaskedUrlQueries: A set of URL query parameter keys that should not be masked.
/// - unmaskedBodyParams: A set of body parameter keys that should not be masked.
public init(
privacy: AnalyticsPrivacy,
unmaskedHeaders: Set<String> = [],
unmaskedUrlQueries: Set<String> = [],
unmaskedBodyParams: Set<String> = []
) {
self.privacy = privacy
self.unmaskedHeaders = unmaskedHeaders
self.unmaskedUrlQueries = unmaskedUrlQueries
self.unmaskedBodyParams = unmaskedBodyParams
}

/// Default analytics configuration with sensitive privacy
public static let `default` = AnalyticsConfiguration(privacy: .sensitive)


// MARK: - Public Masking Methods

public func maskUrl(_ url: String?) -> String? {
guard let url = url else { return nil }

switch privacy {
case .none:
return url
case .private:
return maskPrivateUrlQueries(url)
case .sensitive:
return maskSensitiveUrlQueries(url)
}
}

private func maskPrivateUrlQueries(_ url: String) -> String {
guard let urlComponents = URLComponents(string: url),
let queryItems = urlComponents.queryItems else { return url }

let maskedQueryItems = queryItems.map { item -> URLQueryItem in
if unmaskedUrlQueries.contains(item.name.lowercased()) {
return item
}
return URLQueryItem(name: item.name, value: "***")
}

var maskedComponents = urlComponents
maskedComponents.queryItems = maskedQueryItems
return maskedComponents.url?.absoluteString ?? url
}

private func maskSensitiveUrlQueries(_ url: String) -> String {
guard let urlComponents = URLComponents(string: url) else { return url }

var maskedComponents = urlComponents
maskedComponents.query = nil

return maskedComponents.url?.absoluteString ?? url
}

public func maskHeaders(_ headers: [String: String]?) -> [String: String]? {
guard let headers = headers else { return nil }

switch privacy {
case .none:
return headers
case .private:
var maskedHeaders: [String: String] = [:]
for (key, value) in headers {
if unmaskedHeaders.contains(key.lowercased()) {
maskedHeaders[key] = value
} else {
maskedHeaders[key] = "***"
}
}
return maskedHeaders
case .sensitive:
return headers.mapValues { _ in "***" }
}
}

public func maskBody(_ body: Data?) -> Data? {
guard let body = body else { return nil }

switch privacy {
case .none:
return body
case .private:
return maskPrivateBodyParams(body)
case .sensitive:
return nil
}
}

private func maskPrivateBodyParams(_ body: Data) -> Data? {
guard let json = try? JSONSerialization.jsonObject(with: body) else {
return "***".data(using: .utf8)
}

let maskedJson = recursivelyMask(json)

return try? JSONSerialization.data(withJSONObject: maskedJson)
}

private func recursivelyMask(_ data: Any) -> Any {
if let dictionary = data as? [String: Any] {
var newDict: [String: Any] = [:]
for (key, value) in dictionary {
if unmaskedBodyParams.contains(key.lowercased()) {
newDict[key] = value
} else {
newDict[key] = recursivelyMask(value)
}
}
return newDict
} else if let array = data as? [Any] {
return array.map { recursivelyMask($0) }
} else {
return "***"
}
}
}
20 changes: 20 additions & 0 deletions Sources/FTAPIKit/Analytics/AnalyticsPrivacy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation

/// Privacy levels for analytics data masking.
///
/// This enum defines the different levels of privacy for analytics data.
/// Each level determines how much information is masked before being sent
/// to the analytics service.
public enum AnalyticsPrivacy {
/// No privacy masking - all data is preserved.
/// This should be used only for development and debugging.
case none

/// Private masking - sensitive data in headers, URL queries and body is masked.
/// Unmasked exceptions can be specified in ``AnalyticsConfiguration``.
case `private`

/// Sensitive masking - all user-specific data is masked.
/// This is the recommended setting for production environments.
case sensitive
}
26 changes: 26 additions & 0 deletions Sources/FTAPIKit/Analytics/AnalyticsProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Foundation

/// Protocol for analytics functionality.
///
/// This protocol defines the interface for tracking network requests, responses, and errors
/// for analytics purposes. It provides privacy-aware data tracking with automatic masking
/// of sensitive information.
///
/// - Note: The ``AnalyticEntry`` passed to the `track` method contains privacy-masked data
/// based on the configured privacy level and sensitive data sets.
public protocol AnalyticsProtocol {
/// Configuration for analytics privacy and masking.
///
/// This configuration determines how sensitive data is masked before being sent
/// to the analytics service.
var configuration: AnalyticsConfiguration { get }

/// Tracks an analytic entry for analytics.
///
/// This method is called automatically by ``URLServer`` implementations
/// for all network requests, responses, and errors. The entry contains
/// privacy-masked data based on the configuration.
///
/// - Parameter entry: The analytic entry containing network activity data
func track(_ entry: AnalyticEntry)
}
13 changes: 13 additions & 0 deletions Sources/FTAPIKit/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,16 @@ Easily extensible for your asynchronous framework or networking stack.

- ``APIError``
- ``APIErrorStandard``

### Logging

- ``LogEntry``
- ``LoggerConfiguration``
- ``LogPrivacy``

### Analytics

- ``AnalyticsProtocol``
- ``AnalyticEntry``
- ``AnalyticsConfiguration``
- ``AnalyticsPrivacy``
45 changes: 45 additions & 0 deletions Sources/FTAPIKit/EntryType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

/// Represents the type of network entry with associated data.
///
/// This enum uses associated values to provide type-safe access to network entry data,
/// eliminating the need for optionals for basic information like method, URL, and status code.
///
/// - Note: This enum is used by both ``LogEntry`` and ``AnalyticEntry`` for consistent
/// type-safe data representation across logging and analytics systems.
public enum EntryType {
/// Represents a network request entry.
/// - Parameters:
/// - method: The HTTP method (e.g., "GET", "POST", "PUT")
/// - url: The request URL
case request(method: String, url: String)

/// Represents a network response entry.
/// - Parameters:
/// - method: The HTTP method that was used
/// - url: The request URL that was called
/// - statusCode: The HTTP status code returned
case response(method: String, url: String, statusCode: Int)

/// Represents a network error entry.
/// - Parameters:
/// - method: The HTTP method that was attempted
/// - url: The request URL that failed
/// - error: The error message describing what went wrong
case error(method: String, url: String, error: String)

/// The raw string representation for backwards compatibility.
///
/// This property provides a string representation of the entry type that can be used
/// for serialization, logging, or analytics tracking.
public var rawValue: String {
switch self {
case .request:
"request"
case .response:
"response"
case .error:
"error"
}
}
}
Loading
Loading