diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 680623b..ca036e3 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -5,6 +5,7 @@ on: tags: - 'v*' - '[0-9]*' + workflow_dispatch: jobs: @@ -13,4 +14,4 @@ jobs: permissions: contents: read pages: write - id-token: write \ No newline at end of file + id-token: write diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..ae6110e --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,31 @@ +name: Pull request + +on: + pull_request: + branches: + - main + +jobs: + + soundness: + name: Soundness + uses: swiftlang/github-workflows/.github/workflows/soundness.yml@0.0.15 + with: + license_header_check_project_name: "project" + format_check_enabled : true + broken_symlink_check_enabled : true + unacceptable_language_check_enabled : true + shell_check_enabled : true + docs_check_enabled : false + api_breakage_check_enabled : false + license_header_check_enabled : false + yamllint_check_enabled : false + python_lint_check_enabled : false + + tests: + name: Tests + uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@0.0.15 + with: + enable_windows_checks : false + linux_build_command: "swift test --parallel --enable-code-coverage" + linux_swift_versions: '["6.3", "6.4"]' diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml deleted file mode 100644 index 4d89771..0000000 --- a/.github/workflows/testing.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Testing - -on: - pull_request: - branches: - - main - -jobs: - - swiftlang_checks: - name: Swiftlang Checks - uses: swiftlang/github-workflows/.github/workflows/soundness.yml@0.0.7 - with: - license_header_check_project_name: "project" - format_check_enabled : true - broken_symlink_check_enabled : true - unacceptable_language_check_enabled : true - shell_check_enabled : true - docs_check_enabled : false - api_breakage_check_enabled : false - license_header_check_enabled : false - yamllint_check_enabled : false - python_lint_check_enabled : false - - bb_checks: - name: BB Checks - uses: BinaryBirds/github-workflows/.github/workflows/extra_soundness.yml@main - with: - local_swift_dependencies_check_enabled : true - headers_check_enabled : true - docc_warnings_check_enabled : true - - swiftlang_tests: - name: Swiftlang Tests - uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main - with: - enable_windows_checks : false - linux_build_command: "swift test --parallel --enable-code-coverage" - linux_exclude_swift_versions: "[{\"swift_version\": \"5.8\"}, {\"swift_version\": \"5.9\"}, {\"swift_version\": \"5.10\"}, {\"swift_version\": \"nightly\"}, {\"swift_version\": \"nightly-main\"}, {\"swift_version\": \"6.0\"}, {\"swift_version\": \"nightly-6.0\"}, {\"swift_version\": \"nightly-6.1\"}, {\"swift_version\": \"nightly-6.3\"}]" diff --git a/Package.resolved b/Package.resolved index 320cd7c..3b80b13 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "3a96a169432fa3e3a6825ae591e8dd28bcb50872f5f911feb31cfe61a8b0c9ba", + "originHash" : "3244b00acf96e248edae83203554eebee546b5ca8df006a6cc59b5120b89ad5e", "pins" : [ { "identity" : "swift-atomics", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" + "revision" : "98ef3c98609a1e31b7e157b5b619579001a789d6", + "version" : "1.7.1" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio", "state" : { - "revision" : "e932d3c4d8f77433c8f7093b5ebcbf91463948a0", - "version" : "2.95.0" + "revision" : "6db9d9abd92ad7399b8a564cafbb87d3507245fa", + "version" : "2.104.0" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" } } ], diff --git a/Package.swift b/Package.swift index 35bed44..8c23ad2 100644 --- a/Package.swift +++ b/Package.swift @@ -1,26 +1,22 @@ -// swift-tools-version:6.1 +// swift-tools-version:6.3 import PackageDescription -// NOTE: https://github.com/swift-server/swift-http-server/blob/main/Package.swift -var defaultSwiftSettings: [SwiftSetting] = -[ - // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0441-formalize-language-mode-terminology.md +let swiftSettings: [SwiftSetting] = [ .swiftLanguageMode(.v6), - // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0444-member-import-visibility.md + .strictMemorySafety(), + .treatAllWarnings(as: .error), + .enableUpcomingFeature("InternalImportsByDefault"), + .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("MemberImportVisibility"), - // https://forums.swift.org/t/experimental-support-for-lifetime-dependencies-in-swift-6-2-and-beyond/78638 + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("ImmutableWeakCaptures"), + .enableExperimentalFeature("SuppressedAssociatedTypes"), + .enableExperimentalFeature("LifetimeDependence"), .enableExperimentalFeature("Lifetimes"), - // https://github.com/swiftlang/swift/pull/65218 - .enableExperimentalFeature("AvailabilityMacro=featherStorage 1.0:macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0"), + .enableUpcomingFeature("StrictConcurrency"), ] -#if compiler(>=6.2) -defaultSwiftSettings.append( - // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md - .enableUpcomingFeature("NonisolatedNonsendingByDefault") -) -#endif - let package = Package( name: "feather-storage", platforms: [ @@ -34,8 +30,7 @@ let package = Package( .library(name: "FeatherStorage", targets: ["FeatherStorage"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio", from: "2.9.0"), - // [docc-plugin-placeholder] + .package(url: "https://github.com/apple/swift-nio", from: "2.100.0"), ], targets: [ .target( @@ -43,7 +38,7 @@ let package = Package( dependencies: [ .product(name: "NIOCore", package: "swift-nio"), ], - swiftSettings: defaultSwiftSettings + swiftSettings: swiftSettings ), .testTarget( name: "FeatherStorageTests", @@ -51,7 +46,7 @@ let package = Package( .target(name: "FeatherStorage"), .product(name: "NIOFoundationCompat", package: "swift-nio"), ], - swiftSettings: defaultSwiftSettings + swiftSettings: swiftSettings ), ] ) diff --git a/README.md b/README.md index 1c846b2..e791232 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Abstract storage component, providing a shared API surface for file storage drivers written in Swift. [ - ![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E2-F05138) + ![Release: 1.0.0-rc.1](https://img.shields.io/badge/Release-1%2E0%2E0--rc%2E1-F05138) ]( - https://github.com/feather-framework/feather-storage/releases/tag/1.0.0-beta.2 + https://github.com/feather-framework/feather-storage/releases/tag/1.0.0-rc.1 ) ## Features @@ -17,10 +17,10 @@ Abstract storage component, providing a shared API surface for file storage driv ## Requirements -![Swift 6.1+](https://img.shields.io/badge/Swift-6%2E1%2B-F05138) +![Swift 6.3+](https://img.shields.io/badge/Swift-6%2E3%2B-F05138) ![Platforms: Linux, macOS, iOS, tvOS, watchOS, visionOS](https://img.shields.io/badge/Platforms-Linux_%7C_macOS_%7C_iOS_%7C_tvOS_%7C_watchOS_%7C_visionOS-F05138) -- Swift 6.1+ +- Swift 6.3+ - Platforms: - Linux @@ -35,7 +35,7 @@ Abstract storage component, providing a shared API surface for file storage driv Use Swift Package Manager; add the dependency to your `Package.swift` file: ```swift -.package(url: "https://github.com/feather-framework/feather-storage", exact: "1.0.0-beta.2"), +.package(url: "https://github.com/feather-framework/feather-storage", exact: "1.0.0-rc.1"), ``` Then add `FeatherStorage` to your target dependencies: diff --git a/Sources/FeatherStorage/ByteBufferSequence.swift b/Sources/FeatherStorage/ByteBufferSequence.swift index f11cdf0..79377f2 100644 --- a/Sources/FeatherStorage/ByteBufferSequence.swift +++ b/Sources/FeatherStorage/ByteBufferSequence.swift @@ -4,7 +4,7 @@ // // Created by Tibor Bödecs on 2023. 01. 16. -import NIOCore +public import NIOCore /// An async sequence that streams a `ByteBuffer` in fixed-size chunks. public struct ByteBufferSequence: AsyncSequence, Sendable { diff --git a/Sources/FeatherStorage/StorageClient.swift b/Sources/FeatherStorage/StorageClient.swift index 49e8179..66862d8 100644 --- a/Sources/FeatherStorage/StorageClient.swift +++ b/Sources/FeatherStorage/StorageClient.swift @@ -12,10 +12,12 @@ public protocol StorageClient: Sendable { /// - Parameters: /// - key: The destination object key. /// - sequence: The source byte stream to upload. + /// - contentType: Optional MIME type stored with the object metadata. /// - Throws: `StorageClientError` if the upload fails. func upload( key: String, - sequence: StorageSequence + sequence: StorageSequence, + contentType: String? ) async throws(StorageClientError) /// Downloads an object as an asynchronous byte stream. @@ -136,6 +138,21 @@ public protocol StorageClient: Sendable { extension StorageClient { + /// Uploads an object without additional metadata. + /// + /// This convenience preserves the original storage API for callers that + /// do not need to provide object metadata. + public func upload( + key: String, + sequence: StorageSequence + ) async throws(StorageClientError) { + try await upload( + key: key, + sequence: sequence, + contentType: nil + ) + } + /// Moves an object from one key to another by copying then deleting the source. /// /// - Parameters: diff --git a/Sources/FeatherStorage/StorageClientError.swift b/Sources/FeatherStorage/StorageClientError.swift index 82acc84..3d14299 100644 --- a/Sources/FeatherStorage/StorageClientError.swift +++ b/Sources/FeatherStorage/StorageClientError.swift @@ -16,5 +16,5 @@ public enum StorageClientError: Error { /// A multipart chunk descriptor is invalid. case invalidMultipartChunk /// Driver-specific underlying failure. - case unknown(Error) + case unknown(any Error) } diff --git a/Sources/FeatherStorage/StorageSequence.swift b/Sources/FeatherStorage/StorageSequence.swift index e97041f..e348d04 100644 --- a/Sources/FeatherStorage/StorageSequence.swift +++ b/Sources/FeatherStorage/StorageSequence.swift @@ -4,7 +4,7 @@ // // Created by Tibor Bodecs on 2023. 01. 16. -import NIOCore +public import NIOCore /// A type-erased async sequence of storage byte buffers. public struct StorageSequence: Sendable, AsyncSequence { diff --git a/Tests/FeatherStorageTests/FeatherStorageTestSuite.swift b/Tests/FeatherStorageTests/FeatherStorageTestSuite.swift index e45b9b1..e521abb 100644 --- a/Tests/FeatherStorageTests/FeatherStorageTestSuite.swift +++ b/Tests/FeatherStorageTests/FeatherStorageTestSuite.swift @@ -12,6 +12,23 @@ import Testing @Suite struct FeatherStorageTestSuite { + @Test + func storageClientUploadPreservesContentType() async throws { + let client = MockStorageClient() + let sequence = StorageSequence( + asyncSequence: [1, 2, 3].byteBufferAsync, + length: 3 + ) + + try await client.upload( + key: "image.webp", + sequence: sequence, + contentType: "image/webp" + ) + + #expect(await client.contentType(for: "image.webp") == "image/webp") + } + @Test func storageClientMoveDeletesSourceOnSuccess() async throws { let client = MockStorageClient() diff --git a/Tests/FeatherStorageTests/Mock/MockStorageClient.swift b/Tests/FeatherStorageTests/Mock/MockStorageClient.swift index e0167d4..7e1b437 100644 --- a/Tests/FeatherStorageTests/Mock/MockStorageClient.swift +++ b/Tests/FeatherStorageTests/Mock/MockStorageClient.swift @@ -40,9 +40,18 @@ struct MockStorageClient: StorageClient { func setObject( key: String, - values: [Int] + values: [Int], + contentType: String? = nil ) async { - await state.setObject(key, values: values) + await state.setObject( + key, + values: values, + contentType: contentType + ) + } + + func contentType(for key: String) async -> String? { + await state.objectContentType(for: key) } func multipartExists( @@ -53,7 +62,8 @@ struct MockStorageClient: StorageClient { func upload( key: String, - sequence: StorageSequence + sequence: StorageSequence, + contentType: String? ) async throws(StorageClientError) { let values: [Int] do { @@ -62,7 +72,11 @@ struct MockStorageClient: StorageClient { catch { throw .unknown(error) } - await state.setObject(key, values: values) + await state.setObject( + key, + values: values, + contentType: contentType + ) } func download( @@ -104,7 +118,11 @@ struct MockStorageClient: StorageClient { guard let values = await state.object(for: source) else { throw .invalidKey } - await state.setObject(destination, values: values) + await state.setObject( + destination, + values: values, + contentType: await state.objectContentType(for: source) + ) } func list( diff --git a/Tests/FeatherStorageTests/Mock/MockStorageState.swift b/Tests/FeatherStorageTests/Mock/MockStorageState.swift index cec932c..03887ba 100644 --- a/Tests/FeatherStorageTests/Mock/MockStorageState.swift +++ b/Tests/FeatherStorageTests/Mock/MockStorageState.swift @@ -7,10 +7,21 @@ actor MockStorageState { var objects: [String: [Int]] = [:] + var objectContentTypes: [String: String] = [:] var multiparts: [String: [Int: [Int]]] = [:] - func setObject(_ key: String, values: [Int]) { + func setObject( + _ key: String, + values: [Int], + contentType: String? = nil + ) { objects[key] = values + if let contentType { + objectContentTypes[key] = contentType + } + else { + objectContentTypes.removeValue(forKey: key) + } } func object(for key: String) -> [Int]? { @@ -25,8 +36,13 @@ actor MockStorageState { objects[key]?.count ?? 0 } + func objectContentType(for key: String) -> String? { + objectContentTypes[key] + } + func deleteObject(_ key: String) { objects.removeValue(forKey: key) + objectContentTypes.removeValue(forKey: key) } func listKeys(prefix: String) -> [String] { diff --git a/Tests/FeatherStorageTests/StorageSequenceTestSuite.swift b/Tests/FeatherStorageTests/StorageSequenceTestSuite.swift index 7a05d97..6f95090 100644 --- a/Tests/FeatherStorageTests/StorageSequenceTestSuite.swift +++ b/Tests/FeatherStorageTests/StorageSequenceTestSuite.swift @@ -107,7 +107,7 @@ struct StorageSequenceTestSuite { func initFromThrowingSequencePropagatesErrors() async { let allocator = ByteBufferAllocator() let sequence = StorageSequence( - asyncSequence: AsyncThrowingStream { + asyncSequence: AsyncThrowingStream { continuation in continuation.yield(Self.makeBuffer([1], allocator: allocator)) continuation.finish(throwing: TestError.failed) diff --git a/docker/tests/Dockerfile b/docker/tests/Dockerfile index 73be175..80800a9 100644 --- a/docker/tests/Dockerfile +++ b/docker/tests/Dockerfile @@ -1,4 +1,4 @@ -FROM swift:6.1 +FROM swift:6.3 WORKDIR /app