├── .github ├── matrix.json ├── renovate.json └── workflows │ ├── danger.yml │ ├── deploy.yml │ └── test.yml ├── .gitignore ├── .swiftlint.yml ├── Dangerfile.swift ├── LICENSE ├── Makefile ├── MultipartFormDataParser.podspec ├── Package.swift ├── README.md ├── Sources └── MultipartFormDataParser │ ├── Error.swift │ ├── Extensions │ └── Data+Util.swift │ ├── MultipartFormData+Sequence.swift │ ├── MultipartFormData.swift │ └── MultipartFormDataParser.swift ├── TestPlan.xctestplan ├── Tests └── MultipartFormDataParserTests │ ├── MultipartFormDataParserTests.swift │ ├── _Extension │ ├── NSImage+Util.swift │ ├── URLSession+Linux.swift │ └── XCTest+Activity.swift │ ├── _TestFunctions │ ├── TestFunction_APIKit.swift │ ├── TestFunction_Alamofire.swift │ └── TestFunction_URLSession.swift │ └── _Util │ ├── Constants.swift │ ├── LinuxImage.swift │ ├── StubURLProtocol.swift │ ├── TestEntity.swift │ ├── TestResource.swift │ └── TestStub.swift └── scripts └── release.sh /.github/matrix.json: -------------------------------------------------------------------------------- 1 | { 2 | "xcode_version": [ 3 | "15.2", 4 | "15.4" 5 | ], 6 | "swift_version": [ 7 | "5.9", 8 | "5.10" 9 | ], 10 | "platform": [ 11 | "platform=macOS", 12 | "platform=macOS,variant=Mac Catalyst", 13 | "platform=iOS Simulator,name=iPhone 15 Pro", 14 | "platform=tvOS Simulator,name=Apple TV 4K (3rd generation)" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>417-72KI/renovate-config" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.github/workflows/danger.yml: -------------------------------------------------------------------------------- 1 | name: Danger 2 | on: 3 | pull_request: 4 | types: [opened, synchronize, edited] 5 | branches: 6 | - main 7 | - hotfix 8 | jobs: 9 | danger: 10 | name: Danger 11 | runs-on: ubuntu-latest 12 | concurrency: 13 | group: ${{ github.head_ref }}-${{ github.workflow }} 14 | cancel-in-progress: true 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Danger 18 | uses: 417-72KI/danger-swiftlint@43b6256431e50e838b15f0ade42669db00308b0f # v6.1 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | tags: '*' 5 | jobs: 6 | podspec: 7 | runs-on: macOS-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Lint 11 | run: pod spec lint 12 | - name: Deploy 13 | env: 14 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} 15 | run: pod trunk push MultipartFormDataParser.podspec 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | paths: 5 | - .github/workflows/test.yml 6 | - Package.* 7 | - Sources/**/*.swift 8 | - Tests/**/*.swift 9 | - .github/matrix.json 10 | concurrency: 11 | group: ${{ github.ref }}-${{ github.workflow }} 12 | cancel-in-progress: true 13 | jobs: 14 | extract-matrix: 15 | name: Extract latest Xcode version from matrix 16 | runs-on: ubuntu-latest 17 | outputs: 18 | xcode-versions: ${{ steps.extract-matrix.outputs.xcode-versions }} 19 | latest: ${{ steps.extract-matrix.outputs.latest-xcode-version }} 20 | swift-versions: ${{ steps.extract-matrix.outputs.swift-versions }} 21 | platforms: ${{ steps.extract-matrix.outputs.platforms }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | - id: extract-matrix 25 | run: | 26 | echo "xcode-versions=$(cat .github/matrix.json | jq -rc '.xcode_version')" >> $GITHUB_OUTPUT 27 | echo "latest-xcode-version=$(cat .github/matrix.json | jq -r '.xcode_version | max')" >> $GITHUB_OUTPUT 28 | echo "swift-versions=$(cat .github/matrix.json | jq -rc '.swift_version')" >> $GITHUB_OUTPUT 29 | echo "platforms=$(cat .github/matrix.json | jq -rc '.platform')" >> $GITHUB_OUTPUT 30 | - name: dump matrix 31 | run: | 32 | echo 'xcode-versions = ${{ steps.extract-matrix.outputs.xcode-versions }}' 33 | echo 'latest-xcode-version = ${{ steps.extract-matrix.outputs.latest-xcode-version }}' 34 | echo 'swift-versions = ${{ steps.extract-matrix.outputs.swift-versions }}' 35 | echo 'platforms = ${{ steps.extract-matrix.outputs.platforms }}' 36 | test-macos: 37 | name: Test 38 | needs: extract-matrix 39 | runs-on: macOS-14 40 | concurrency: 41 | group: ${{ github.head_ref }}-${{ github.workflow }}-${{ matrix.xcode }}-${{ matrix.destination }} 42 | cancel-in-progress: true 43 | strategy: 44 | matrix: 45 | xcode: ${{ fromJson(needs.extract-matrix.outputs.xcode-versions) }} 46 | destination: ${{ fromJson(needs.extract-matrix.outputs.platforms) }} 47 | fail-fast: false 48 | env: 49 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer 50 | steps: 51 | - uses: actions/checkout@v4 52 | - id: create-destination-key 53 | name: Create destination key for cache 54 | run: echo "destination-key=$(echo "${{ matrix.destination }}" | sed -r 's/[, ]/_/g')" >> $GITHUB_OUTPUT 55 | - uses: actions/cache@v4 56 | with: 57 | path: | 58 | .build/SourcePackages/checkouts 59 | key: ${{ runner.os }}-xcode-${{ matrix.xcode }}-${{ steps.create-destination-key.outputs.destination-key }}-${{ hashFiles('Package.swift') }} 60 | restore-keys: | 61 | ${{ runner.os }}-xcode-${{ matrix.xcode }}-${{ steps.create-destination-key.outputs.destination-key }}- 62 | - name: Enable macro and plugin 63 | run: | 64 | defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES 65 | defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES 66 | - name: test 67 | run: | 68 | defaults write com.apple.dt.XCBuild EnableSwiftBuildSystemIntegration 1 69 | set -o pipefail && \ 70 | rm -rf 'MultipartFormDataParser.xcodeproj' && \ 71 | xcrun xcodebuild \ 72 | -enableCodeCoverage YES \ 73 | -scheme MultipartFormDataParser \ 74 | -destination "${{ matrix.destination }}" \ 75 | -derivedDataPath '.build' \ 76 | -resultBundlePath 'test_output/TestResult.xcresult' \ 77 | clean test | xcpretty 78 | - name: Upload test result 79 | uses: actions/upload-artifact@v4 80 | if: ${{ matrix.xcode == needs.extract-matrix.outputs.latest && (success() || failure()) }} 81 | with: 82 | name: ${{ steps.create-destination-key.outputs.destination-key }} 83 | path: test_output 84 | if-no-files-found: error 85 | retention-days: 1 86 | xcodebuild_result: 87 | name: Export xcodebuild test result 88 | needs: test-macos 89 | runs-on: macOS-14 90 | steps: 91 | - uses: actions/download-artifact@v4 92 | with: 93 | path: test_output 94 | - name: Merge xcresult files 95 | run: 96 | xcrun xcresulttool merge test_output/**/*.xcresult --output-path test_output/TestResults.xcresult 97 | - uses: kishikawakatsumi/xcresulttool@v1 98 | if: success() || failure() 99 | with: 100 | path: test_output/TestResults.xcresult 101 | show-passed-tests: false 102 | show-code-coverage: false 103 | upload-bundles: true 104 | test-linux: 105 | name: Test 106 | needs: extract-matrix 107 | runs-on: ubuntu-latest 108 | container: swift:${{ matrix.swift }} 109 | concurrency: 110 | group: ${{ github.head_ref }}-${{ github.workflow }}-${{ matrix.swift }} 111 | cancel-in-progress: true 112 | strategy: 113 | matrix: 114 | swift: ${{ fromJson(needs.extract-matrix.outputs.swift-versions) }} 115 | fail-fast: false 116 | steps: 117 | - uses: actions/checkout@v4 118 | - uses: actions/cache@v4 119 | with: 120 | path: | 121 | .build/SourcePackages/checkouts 122 | key: ${{ runner.os }}-xcode-${{ matrix.swift }}-${{ hashFiles('Package.swift') }} 123 | restore-keys: | 124 | ${{ runner.os }}-xcode-${{ matrix.swift }}- 125 | - name: test 126 | run: swift test 127 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/swift,swiftpm 3 | # Edit at https://www.gitignore.io/?templates=swift,swiftpm 4 | 5 | ### Swift ### 6 | # Xcode 7 | # 8 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 9 | 10 | ## Build generated 11 | build/ 12 | DerivedData/ 13 | 14 | ## Various settings 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | xcuserdata/ 24 | 25 | ## Other 26 | *.moved-aside 27 | *.xccheckout 28 | *.xcscmblueprint 29 | 30 | ## Obj-C/Swift specific 31 | *.hmap 32 | *.ipa 33 | *.dSYM.zip 34 | *.dSYM 35 | 36 | ## Playgrounds 37 | timeline.xctimeline 38 | playground.xcworkspace 39 | 40 | # Swift Package Manager 41 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 42 | # Packages/ 43 | # Package.pins 44 | Package.resolved 45 | .build/ 46 | # Add this line if you want to avoid checking in Xcode SPM integration. 47 | .swiftpm/xcode 48 | 49 | # CocoaPods 50 | # We recommend against adding the Pods directory to your .gitignore. However 51 | # you should judge for yourself, the pros and cons are mentioned at: 52 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 53 | # Pods/ 54 | # Add this line if you want to avoid checking in source code from the Xcode workspace 55 | # *.xcworkspace 56 | 57 | # Carthage 58 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 59 | # Carthage/Checkouts 60 | 61 | Carthage/Build 62 | 63 | # Accio dependency management 64 | Dependencies/ 65 | .accio/ 66 | 67 | # fastlane 68 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 69 | # screenshots whenever they are needed. 70 | # For more information about the recommended setup visit: 71 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 72 | 73 | fastlane/report.xml 74 | fastlane/Preview.html 75 | fastlane/screenshots/**/*.png 76 | fastlane/test_output 77 | 78 | # Code Injection 79 | # After new code Injection tools there's a generated folder /iOSInjectionProject 80 | # https://github.com/johnno1962/injectionforxcode 81 | 82 | iOSInjectionProject/ 83 | 84 | ### SwiftPM ### 85 | Packages 86 | xcuserdata 87 | *.xcodeproj 88 | 89 | 90 | # End of https://www.gitignore.io/api/swift,swiftpm 91 | 92 | test_output 93 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Sources 3 | - Tests 4 | excluded: 5 | - .build 6 | opt_in_rules: [all] 7 | disabled_rules: 8 | - anonymous_argument_in_multiline_closure 9 | - conditional_returns_on_newline 10 | - contrasted_opening_brace 11 | - discouraged_optional_collection 12 | - explicit_acl 13 | - explicit_enum_raw_value 14 | - explicit_top_level_acl 15 | - explicit_type_interface 16 | - extension_access_modifier 17 | - file_header 18 | - file_name 19 | - file_types_order 20 | - force_try 21 | - force_unwrapping 22 | - function_default_parameter_at_end 23 | - indentation_width 24 | - lower_acl_than_parent 25 | - missing_docs 26 | - no_extension_access_modifier 27 | - no_grouping_extension 28 | - no_magic_numbers 29 | - non_overridable_class_declaration 30 | - object_literal 31 | - prefer_nimble 32 | - prefixed_toplevel_constant 33 | - prohibited_interface_builder 34 | - required_deinit 35 | - sorted_enum_cases 36 | - static_over_final_class 37 | - strict_fileprivate 38 | - strong_iboutlet 39 | - switch_case_on_newline 40 | - unowned_variable_capture 41 | - unused_optional_binding 42 | - unused_parameter 43 | - vertical_whitespace_between_cases 44 | line_length: 45 | warning: 180 46 | ignores_urls: true 47 | ignores_function_declarations: true 48 | ignores_comments: true 49 | ignores_interpolated_strings: true 50 | file_length: 51 | ignore_comment_only_lines: true 52 | attributes: 53 | always_on_same_line: 54 | - '@IBAction' 55 | - '@NSManaged' 56 | - '@objc' 57 | cyclomatic_complexity: 58 | ignores_case_statements: true 59 | identifier_name: 60 | excluded: 61 | - ^i$ 62 | - ^id$ 63 | multiline_arguments: 64 | only_enforce_after_first_closure_on_first_line: true 65 | nesting: 66 | type_level: 2 67 | number_separator: 68 | minimum_length: 5 69 | trailing_comma: 70 | mandatory_comma: true 71 | trailing_closure: 72 | only_single_muted_parameter: true 73 | -------------------------------------------------------------------------------- /Dangerfile.swift: -------------------------------------------------------------------------------- 1 | import Danger 2 | 3 | let danger = Danger() 4 | 5 | SwiftLint.lint(.modifiedAndCreatedFiles(directory: "Sources"), inline: true) 6 | 7 | let git = danger.git 8 | 9 | if git.modifiedFiles.contains("MultipartFormDataParser.xcodeproj") { 10 | danger.fail("Do not modify xcodeproj. This file is modified only on release.") 11 | } 12 | 13 | if git.modifiedFiles.contains("LICENSE") { 14 | danger.fail("Do not modify LICENSE !!") 15 | } 16 | 17 | if git.deletedFiles.contains("LICENSE") { 18 | danger.fail("Do not delete LICENSE !!") 19 | } 20 | 21 | if let github = danger.github { 22 | if github.pullRequest.title.lowercased().contains("[wip]") { 23 | danger.warn("PR is classed as Work in Progress") 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Takuhiro Muta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT_NAME = MultipartFormDataParser 2 | 3 | ver = 2.3.1 4 | 5 | .SILENT: 6 | 7 | test: 8 | rm -rf test_output 9 | xcrun -sdk macosx xcodebuild \ 10 | -scheme ${PROJECT_NAME} \ 11 | -destination 'platform=macOS' \ 12 | -enableCodeCoverage=YES \ 13 | -resultBundlePath "test_output/test_result.xcresult" \ 14 | test | xcpretty 15 | xed test_output/test_result.xcresult 16 | 17 | release: 18 | @scripts/release.sh ${PROJECT_NAME} ${ver} 19 | -------------------------------------------------------------------------------- /MultipartFormDataParser.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = "MultipartFormDataParser" 3 | spec.version = "2.3.1" 4 | spec.summary = "Mocking UserDefaults for tests" 5 | 6 | spec.description = <<-DESC 7 | MultipartFormDataParser is a testing tool for `multipart/form-data` request in Swift. 8 | This library provides a parser for `multipart/form-data` request to test it briefly. 9 | DESC 10 | 11 | spec.homepage = "https://github.com/417-72KI/#{spec.name}" 12 | spec.readme = "https://github.com/417-72KI/#{spec.name}/blob/#{spec.version}/README.md" 13 | spec.license = { :type => "MIT", :file => "LICENSE" } 14 | 15 | spec.author = { "417.72KI" => "417.72ki@gmail.com" } 16 | spec.social_media_url = "https://twitter.com/417_72ki" 17 | 18 | spec.osx.deployment_target = "13.0" 19 | spec.ios.deployment_target = "16.0" 20 | spec.tvos.deployment_target = "16.0" 21 | 22 | spec.requires_arc = true 23 | 24 | spec.source = { :git => "https://github.com/417-72KI/#{spec.name}.git", :tag => "#{spec.version}" } 25 | spec.source_files = 'Sources/MultipartFormDataParser/**/*.swift' 26 | spec.swift_versions = ['5.9', '5.10'] 27 | spec.frameworks = 'Foundation' 28 | end 29 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let isDevelop = true 7 | let isApplePlatform: Bool = { 8 | #if canImport(Darwin) 9 | true 10 | #else 11 | false 12 | #endif 13 | }() 14 | 15 | let package = Package( 16 | name: "MultipartFormDataParser", 17 | platforms: [ 18 | .macOS(.v13), 19 | .macCatalyst(.v16), 20 | .iOS(.v16), 21 | .tvOS(.v16) 22 | ], 23 | products: [ 24 | .library(name: "MultipartFormDataParser", targets: ["MultipartFormDataParser"]), 25 | ], 26 | dependencies: [], 27 | targets: [ 28 | .target(name: "MultipartFormDataParser"), 29 | .testTarget(name: "MultipartFormDataParserTests", dependencies: ["MultipartFormDataParser"]), 30 | ] 31 | ) 32 | 33 | // MARK: - develop 34 | if isDevelop { 35 | if isApplePlatform { 36 | package.dependencies.append(contentsOf: [ 37 | .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.57.0"), 38 | .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.7.0"), 39 | .package(url: "https://github.com/ishkawa/APIKit.git", from: "5.4.0"), 40 | ]) 41 | package.targets 42 | .filter(\.isTest) 43 | .forEach { 44 | $0.dependencies.append(contentsOf: [ 45 | "Alamofire", 46 | "APIKit", 47 | ]) 48 | } 49 | package.targets.forEach { 50 | if $0.plugins == nil { 51 | $0.plugins = [] 52 | } 53 | $0.plugins?.append(.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins")) 54 | } 55 | } 56 | } 57 | 58 | // MARK: - Upcoming feature flags for Swift 6 59 | package.targets.forEach { 60 | $0.swiftSettings = [ 61 | // .forwardTrailingClosures, 62 | .existentialAny, 63 | .bareSlashRegexLiterals, 64 | .conciseMagicFile, 65 | .importObjcForwardDeclarations, 66 | .disableOutwardActorInference, 67 | // TODO: enable when 5.9 dropped 68 | // .deprecateApplicationMain, 69 | // .isolatedDefaultValues, 70 | // .globalConcurrency, 71 | ] 72 | } 73 | 74 | // ref: https://github.com/treastrain/swift-upcomingfeatureflags-cheatsheet 75 | private extension SwiftSetting { 76 | static let forwardTrailingClosures: Self = .enableUpcomingFeature("ForwardTrailingClosures") // SE-0286, Swift 5.3, SwiftPM 5.8+ 77 | static let existentialAny: Self = .enableUpcomingFeature("ExistentialAny") // SE-0335, Swift 5.6, SwiftPM 5.8+ 78 | static let bareSlashRegexLiterals: Self = .enableUpcomingFeature("BareSlashRegexLiterals") // SE-0354, Swift 5.7, SwiftPM 5.8+ 79 | static let conciseMagicFile: Self = .enableUpcomingFeature("ConciseMagicFile") // SE-0274, Swift 5.8, SwiftPM 5.8+ 80 | static let importObjcForwardDeclarations: Self = .enableUpcomingFeature("ImportObjcForwardDeclarations") // SE-0384, Swift 5.9, SwiftPM 5.9+ 81 | static let disableOutwardActorInference: Self = .enableUpcomingFeature("DisableOutwardActorInference") // SE-0401, Swift 5.9, SwiftPM 5.9+ 82 | static let deprecateApplicationMain: Self = .enableUpcomingFeature("DeprecateApplicationMain") // SE-0383, Swift 5.10, SwiftPM 5.10+ 83 | static let isolatedDefaultValues: Self = .enableUpcomingFeature("IsolatedDefaultValues") // SE-0411, Swift 5.10, SwiftPM 5.10+ 84 | static let globalConcurrency: Self = .enableUpcomingFeature("GlobalConcurrency") // SE-0412, Swift 5.10, SwiftPM 5.10+ 85 | } 86 | 87 | // MARK: - Enabling Complete Concurrency Checking for Swift 6 88 | // ref: https://www.swift.org/documentation/concurrency/ 89 | package.targets.forEach { 90 | var settings = $0.swiftSettings ?? [] 91 | settings.append(.enableExperimentalFeature("StrictConcurrency")) 92 | $0.swiftSettings = settings 93 | } 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MultipartFormDataParser 2 | [![Actions Status](https://github.com/417-72KI/MultipartFormDataParser/workflows/Test/badge.svg)](https://github.com/417-72KI/MultipartFormDataParser/actions) 3 | [![Version](http://img.shields.io/cocoapods/v/MultipartFormDataParser.svg?style=flat)](http://cocoapods.org/pods/MultipartFormDataParser) 4 | [![Platform](http://img.shields.io/cocoapods/p/MultipartFormDataParser.svg?style=flat)](http://cocoapods.org/pods/MultipartFormDataParser) 5 | [![GitHub release](https://img.shields.io/github/release/417-72KI/MultipartFormDataParser/all.svg)](https://github.com/417-72KI/MultipartFormDataParser/releases) 6 | [![Swift Package Manager](https://img.shields.io/badge/Swift%20Package%20Manager-5.7-brightgreen.svg)](https://github.com/apple/swift-package-manager) 7 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2F417-72KI%2FMultipartFormDataParser%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/417-72KI/MultipartFormDataParser) 8 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2F417-72KI%2FMultipartFormDataParser%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/417-72KI/MultipartFormDataParser) 9 | [![GitHub license](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://raw.githubusercontent.com/417-72KI/MultipartFormDataParser/master/LICENSE) 10 | 11 | `MultipartFormDataParser` is a testing tool for `multipart/form-data` request in Swift. 12 | 13 | When to upload some files via API, we must use `multipart/form-data` for request. 14 | `multipart/form-data` is defined as [RFC-2388](https://www.ietf.org/rfc/rfc2388.txt) 15 | 16 | Most famous networking libraries (e.g. [Alamofire](https://github.com/Alamofire/Alamofire), [APIKit](https://github.com/ishkawa/APIKit)) can implement easily. 17 | However, to test if the created request is as expected is difficult and bothering. 18 | 19 | This library provides a parser for `multipart/form-data` request to test it briefly. 20 | 21 | ```swift 22 | let request: URLRequest = ... 23 | do { 24 | let data = try MultipartFormData.parse(from: request) 25 | let genbaNeko = try XCTUnwrap(data.element(forName: "genbaNeko")) 26 | let message = try XCTUnwrap(data.element(forName: "message")) 27 | XCTAssertNotNil(Image(data: genbaNeko.data)) 28 | XCTAssertEqual(genbaNeko.mimeType, "image/jpeg") 29 | XCTAssertEqual(message.string, "Hello world!") 30 | } catch { 31 | XCTFail(error.localizedDescription) 32 | } 33 | ``` 34 | 35 | Using [OHHTTPStubs](https://github.com/AliSoftware/OHHTTPStubs), we can test a request created by networking libraries easily. 36 | ```swift 37 | let expectedGenbaNeko: Data = ... 38 | 39 | let condition = isHost("localhost") && isPath("/upload") 40 | stub(condition: condition) { request in 41 | let errorResponse = { (message: String) -> HTTPStubsResponse in 42 | .init( 43 | jsonObject: ["status": 403, "error": message], 44 | statusCode: 403, 45 | headers: ["Content-Type": "application/json"] 46 | ) 47 | } 48 | do { 49 | let data = try MultipartFormData.parse(from: request) 50 | guard let genbaNeko = data.element(forName: "genbaNeko"), 51 | genbaNeko.data == expectedGenbaNeko else { return errorResponse("Unexpected genbaNeko") } 52 | guard let message = data.element(forName: "message"), 53 | message.string == "Hello world!" else { return errorResponse("Unexpected message: \(message)") } 54 | } catch { 55 | return .init(error: error) 56 | } 57 | return .init( 58 | jsonObject: ["status": 200], 59 | statusCode: 200, 60 | headers: ["Content-Type": "application/json"] 61 | ) 62 | } 63 | ``` 64 | 65 | ## Installation 66 | ### Swift Package Manager (recommended) 67 | Package.swift 68 | 69 | ```swift 70 | dependencies: [ 71 | .package(url: "https://github.com/417-72KI/MultipartFormDataParser.git", from: "2.3.1") 72 | ] 73 | ``` 74 | 75 | ### CocoaPods 76 | Podfile 77 | 78 | ```ruby 79 | pod 'MultipartFormDataParser' 80 | ``` 81 | -------------------------------------------------------------------------------- /Sources/MultipartFormDataParser/Error.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum MultipartFormDataError: LocalizedError { 4 | case noContentType 5 | case invalidContentType(String) 6 | case httpBodyStreamEmpty 7 | case invalidHttpBodyStream 8 | 9 | // case testFailed(String) 10 | // case notImplemented 11 | } 12 | 13 | extension MultipartFormDataError { 14 | public var errorDescription: String? { 15 | switch self { 16 | case .noContentType: return "No Content-Type" 17 | case let .invalidContentType(contentType): return "Invalid Content-Type: \(contentType)" 18 | 19 | case .httpBodyStreamEmpty: return "HTTP body stream is empty." 20 | case .invalidHttpBodyStream: return "Invalid stream." 21 | 22 | // case let .testFailed(reason): return "Test failed: \(reason)" 23 | // case .notImplemented: return "Not implemented" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/MultipartFormDataParser/Extensions/Data+Util.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Data { 4 | var bytes: [UInt8] { withUnsafeBytes([UInt8].init) } 5 | } 6 | 7 | extension Data { 8 | func split(separator: [UInt8]) -> [Data] { 9 | let bytes = self.bytes 10 | var result = [Data]() 11 | var position = 0 12 | for i in 0.. 0 { 16 | result.append(self[position.. [Data] { 25 | split(separator: separator.bytes) 26 | } 27 | 28 | func split(separator: String) -> [Data] { 29 | split(separator: Data(separator.utf8)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/MultipartFormDataParser/MultipartFormData+Sequence.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension MultipartFormData: Sequence { 4 | public typealias Iterator = Array.Iterator 5 | 6 | public func makeIterator() -> Iterator { 7 | elements.makeIterator() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/MultipartFormDataParser/MultipartFormData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | public struct MultipartFormData { 7 | public let elements: [Element] 8 | } 9 | 10 | public extension MultipartFormData { 11 | func element(forName name: String) -> Element? { 12 | elements.first(where: { $0.name == name }) 13 | } 14 | } 15 | 16 | // MARK: - Static functions 17 | public extension MultipartFormData { 18 | static func parse(from request: URLRequest) throws -> Self { 19 | try MultipartFormDataParser.parse(request) 20 | } 21 | } 22 | 23 | // MARK: - 24 | public extension MultipartFormData { 25 | struct Element { 26 | public private(set) var name: String 27 | public private(set) var data: Data 28 | public private(set) var fileName: String? 29 | public private(set) var mimeType: String? 30 | } 31 | } 32 | 33 | public extension MultipartFormData.Element { 34 | var string: String? { String(data: data, encoding: .utf8) } 35 | } 36 | 37 | extension MultipartFormData.Element { 38 | static func from(_ data: [Data]) -> Self { 39 | var element = Self(name: "", data: .init(), fileName: nil, mimeType: nil) 40 | for line in data { 41 | guard let string = String(data: line, encoding: .utf8) else { 42 | element.data = line 43 | continue 44 | } 45 | 46 | if let contentDispositionMatches = try! #/Content-Disposition: form-data; name="(?.*?)"(; filename="(?.*?)")?/#.firstMatch(in: string) { 47 | element.name = String(contentDispositionMatches.output.name) 48 | if let filename = contentDispositionMatches.output.filename { 49 | element.fileName = String(filename) 50 | } 51 | continue 52 | } 53 | if let mimeTypeMatches = try! #/Content-Type: (?.*/.*)/#.firstMatch(in: string) { 54 | element.mimeType = String(mimeTypeMatches.output.mimetype) 55 | continue 56 | } 57 | if element.data.isEmpty { 58 | element.data = line 59 | } 60 | } 61 | return element 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/MultipartFormDataParser/MultipartFormDataParser.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | private let crlf = "\r\n" 7 | 8 | struct MultipartFormDataParser: Sendable { 9 | private let boundary: String 10 | } 11 | 12 | // MARK: - Functions 13 | private extension MultipartFormDataParser { 14 | func parse(_ stream: InputStream) throws -> MultipartFormData { 15 | try parse(extractData(from: stream)) 16 | } 17 | 18 | func parse(_ data: Data) throws -> MultipartFormData { 19 | let data = data.split(separator: crlf) 20 | let elements = try split(data, withBoundary: boundary) 21 | .compactMap(MultipartFormData.Element.from) 22 | return MultipartFormData(elements: elements) 23 | } 24 | 25 | func extractData(from stream: InputStream) -> Data { 26 | stream.open() 27 | defer { stream.close() } 28 | var data = Data() 29 | while stream.hasBytesAvailable { 30 | var buffer = [UInt8](repeating: 0, count: 512) 31 | let readCount = stream.read(&buffer, maxLength: buffer.count) 32 | guard readCount > 0 else { break } 33 | data.append(buffer, count: readCount) 34 | } 35 | return data 36 | } 37 | 38 | func split(_ data: [Data], withBoundary boundary: String) throws -> [[Data]] { 39 | var result = [[Data]]() 40 | for line in data { 41 | switch String(data: line, encoding: .utf8) { 42 | case "--\(boundary)--": // end of body 43 | return result 44 | case "--\(boundary)": 45 | result.append([]) 46 | default: 47 | if let last = result.indices.last { 48 | result[last].append(line) 49 | } 50 | } 51 | } 52 | throw MultipartFormDataError.invalidHttpBodyStream 53 | } 54 | } 55 | 56 | // MARK: - Static functions 57 | extension MultipartFormDataParser { 58 | static func parse(_ request: URLRequest) throws -> MultipartFormData { 59 | guard let contentType = request.value(forHTTPHeaderField: "Content-Type") else { 60 | throw MultipartFormDataError.noContentType 61 | } 62 | let regex = #/multipart/form-data; boundary=(.*)/# 63 | guard let boundaryMatches = try! regex.firstMatch(in: contentType) else { 64 | throw MultipartFormDataError.invalidContentType(contentType) 65 | } 66 | let boundary = String(boundaryMatches.output.1) 67 | if let body = request.httpBody, !body.isEmpty { 68 | return try Self(boundary: boundary).parse(body) 69 | } 70 | guard let stream = request.httpBodyStream else { 71 | throw MultipartFormDataError.httpBodyStreamEmpty 72 | } 73 | return try Self(boundary: boundary).parse(stream) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /TestPlan.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "D06DC996-5C46-43C0-A9F0-BF25ED72E3C9", 5 | "name" : "Default", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "maximumTestRepetitions" : 5, 13 | "nsZombieEnabled" : true, 14 | "repeatInNewRunnerProcess" : true, 15 | "testRepetitionMode" : "retryOnFailure", 16 | "testTimeoutsEnabled" : true 17 | }, 18 | "testTargets" : [ 19 | { 20 | "target" : { 21 | "containerPath" : "container:", 22 | "identifier" : "MultipartFormDataParserTests", 23 | "name" : "MultipartFormDataParserTests" 24 | } 25 | } 26 | ], 27 | "version" : 1 28 | } 29 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/MultipartFormDataParserTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | import MultipartFormDataParser 7 | 8 | #if canImport(UIKit) 9 | import UIKit 10 | #elseif canImport(Cocoa) 11 | import Cocoa 12 | #endif 13 | 14 | final class MultipartFormDataParserTests: XCTestCase { 15 | override class func setUp() { 16 | stubForUpload() 17 | } 18 | 19 | override class func tearDown() { 20 | clearStubs() 21 | } 22 | 23 | func testRequest() throws { 24 | let genbaNeko = try XCTUnwrap(genbaNeko) 25 | let denwaNeko = try XCTUnwrap(denwaNeko) 26 | let message = Data("Hello world!".utf8) 27 | let request = createRequest(genbaNeko: genbaNeko, denwaNeko: denwaNeko, message: message) 28 | let data = try MultipartFormData.parse(from: request) 29 | XCTAssertEqual(data.element(forName: "genbaNeko")?.data, genbaNeko) 30 | XCTAssertEqual(data.element(forName: "denwaNeko")?.data, denwaNeko) 31 | XCTAssertEqual(data.element(forName: "message")?.string, "Hello world!") 32 | } 33 | 34 | func testSequence() throws { 35 | let genbaNeko = try XCTUnwrap(genbaNeko) 36 | let denwaNeko = try XCTUnwrap(denwaNeko) 37 | let message = Data("Hello world!".utf8) 38 | let request = createRequest(genbaNeko: genbaNeko, denwaNeko: denwaNeko, message: message) 39 | let data = try MultipartFormData.parse(from: request) 40 | XCTAssertEqual(["genbaNeko", "denwaNeko", "message"], data.map(\.name)) 41 | XCTAssertEqual([genbaNeko, denwaNeko, Data("Hello world!".utf8)], data.map(\.data)) 42 | } 43 | 44 | // MARK: Failure 45 | func testEmptyBody() throws { 46 | var request = URLRequest(url: URL(string: "https://localhost/empty")!) 47 | request.httpMethod = "POST" 48 | request.setValue("multipart/form-data; boundary=foobar", forHTTPHeaderField: "Content-Type") 49 | request.httpBody = Data("".utf8) 50 | XCTAssertThrowsError(try MultipartFormData.parse(from: request)) { 51 | XCTAssertEqual($0.localizedDescription, "HTTP body stream is empty.") 52 | } 53 | } 54 | 55 | func testNoContentType() throws { 56 | let request = URLRequest(url: URL(string: "https://localhost/empty")!) 57 | XCTAssertThrowsError(try MultipartFormData.parse(from: request)) { 58 | XCTAssertEqual($0.localizedDescription, "No Content-Type") 59 | } 60 | } 61 | 62 | func testInvalidContentType() throws { 63 | var request = URLRequest(url: URL(string: "https://localhost/empty")!) 64 | request.httpMethod = "POST" 65 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 66 | request.httpBody = Data("".utf8) 67 | XCTAssertThrowsError(try MultipartFormData.parse(from: request)) { 68 | XCTAssertEqual($0.localizedDescription, "Invalid Content-Type: application/json") 69 | } 70 | } 71 | 72 | func testInvalidBody() throws { 73 | var request = URLRequest(url: URL(string: "https://localhost/empty")!) 74 | request.httpMethod = "POST" 75 | request.setValue("multipart/form-data; boundary=foobar", forHTTPHeaderField: "Content-Type") 76 | 77 | // body not contains `CRLF` 78 | request.httpBody = Data("--foobar".utf8) 79 | XCTAssertThrowsError(try MultipartFormData.parse(from: request)) { 80 | XCTAssertEqual($0.localizedDescription, "Invalid stream.") 81 | } 82 | 83 | // body not started with boundary 84 | request.httpBody = Data("foobar\r\n".utf8) 85 | XCTAssertThrowsError(try MultipartFormData.parse(from: request)) { 86 | XCTAssertEqual($0.localizedDescription, "Invalid stream.") 87 | } 88 | 89 | // body not end with `--{boundary}--` 90 | request.httpBody = Data("--foobar\r\n".utf8) 91 | XCTAssertThrowsError(try MultipartFormData.parse(from: request)) { 92 | XCTAssertEqual($0.localizedDescription, "Invalid stream.") 93 | } 94 | } 95 | 96 | // MARK: using 3rd-party libraries 97 | #if canImport(Alamofire) 98 | func testAlamofire() throws { 99 | let genbaNeko = try XCTUnwrap(genbaNeko) 100 | let denwaNeko = try XCTUnwrap(denwaNeko) 101 | let message = Data("Hello world!".utf8) 102 | 103 | let result = try XCTUnwrap(uploadWithAlamoFire(genbaNeko: genbaNeko, denwaNeko: denwaNeko, message: message)) 104 | XCTAssertEqual(result.status, 200) 105 | XCTAssertNil(result.error) 106 | } 107 | 108 | func testAlamofireWithConcurrency() async throws { 109 | let genbaNeko = try XCTUnwrap(genbaNeko) 110 | let denwaNeko = try XCTUnwrap(denwaNeko) 111 | let message = Data("Hello world!".utf8) 112 | let result = try await uploadWithAlamoFireConcurrency(genbaNeko: genbaNeko, denwaNeko: denwaNeko, message: message) 113 | XCTAssertEqual(result.status, 200) 114 | XCTAssertNil(result.error) 115 | } 116 | #endif 117 | 118 | #if canImport(APIKit) 119 | func testAPIKit() throws { 120 | let genbaNeko = try XCTUnwrap(genbaNeko) 121 | let denwaNeko = try XCTUnwrap(denwaNeko) 122 | let message = Data("Hello world!".utf8) 123 | 124 | try runActivity(named: "request") { 125 | let request = try requestWithAPIKit( 126 | genbaNeko: genbaNeko, 127 | denwaNeko: denwaNeko, 128 | message: message 129 | ) 130 | let data = try MultipartFormData.parse(from: request) 131 | XCTAssertEqual(data.element(forName: "genbaNeko")?.data, genbaNeko) 132 | XCTAssertEqual(data.element(forName: "denwaNeko")?.data, denwaNeko) 133 | XCTAssertEqual(data.element(forName: "message")?.string, "Hello world!") 134 | } 135 | 136 | try runActivity(named: "stub") { 137 | let result = try XCTUnwrap(uploadWithAPIKit(genbaNeko: genbaNeko, denwaNeko: denwaNeko, message: message)) 138 | XCTAssertEqual(result.status, 200) 139 | XCTAssertNil(result.error) 140 | } 141 | } 142 | #endif 143 | 144 | // MARK: URLSession 145 | func testURLSessionUploadTask() async throws { 146 | #if os(Linux) 147 | // FIXME: There is no way to get body stream with `URLSessionUploadTask`. 148 | try XCTSkipIf(true, "Stubbing `URLSessionUploadTask` in Linux is not supported.") 149 | #endif 150 | let genbaNeko = try XCTUnwrap(genbaNeko) 151 | let denwaNeko = try XCTUnwrap(denwaNeko) 152 | let message = Data("Hello world!".utf8) 153 | let result = try await uploadURLSessionUpload(genbaNeko: genbaNeko, denwaNeko: denwaNeko, message: message) 154 | XCTAssertEqual(result.status, 200) 155 | XCTAssertNil(result.error) 156 | } 157 | 158 | func testURLSessionDataTask() async throws { 159 | let genbaNeko = try XCTUnwrap(genbaNeko) 160 | let denwaNeko = try XCTUnwrap(denwaNeko) 161 | let message = Data("Hello world!".utf8) 162 | let result = try await uploadURLSessionData(genbaNeko: genbaNeko, denwaNeko: denwaNeko, message: message) 163 | XCTAssertEqual(result.status, 200) 164 | XCTAssertNil(result.error) 165 | } 166 | } 167 | 168 | private extension MultipartFormDataParserTests { 169 | var genbaNeko: Data? { 170 | #if canImport(UIKit) 171 | return UIImage(data: TestResource.genbaNeko)? 172 | .jpegData(compressionQuality: 1) 173 | #elseif canImport(Cocoa) 174 | return NSImage(data: TestResource.genbaNeko)? 175 | .jpegRepresentation 176 | #elseif os(Linux) 177 | return Image(data: TestResource.genbaNeko)?.data 178 | #else 179 | return TestResource.genbaNeko 180 | #endif 181 | } 182 | 183 | var denwaNeko: Data? { 184 | #if canImport(UIKit) 185 | return UIImage(data: TestResource.denwaNeko)? 186 | .jpegData(compressionQuality: 1) 187 | #elseif canImport(Cocoa) 188 | return NSImage(data: TestResource.denwaNeko)? 189 | .jpegRepresentation 190 | #elseif os(Linux) 191 | return Image(data: TestResource.denwaNeko)?.data 192 | #else 193 | return TestResource.denwaNeko 194 | #endif 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/_Extension/NSImage+Util.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Cocoa) && !targetEnvironment(macCatalyst) 2 | import Cocoa 3 | 4 | extension NSImage { 5 | var jpegRepresentation: Data? { 6 | guard let bitmap = representations.first as? NSBitmapImageRep else { return nil } 7 | return bitmap.representation(using: .jpeg, properties: [:]) 8 | } 9 | } 10 | #endif 11 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/_Extension/URLSession+Linux.swift: -------------------------------------------------------------------------------- 1 | #if canImport(FoundationNetworking) 2 | import Foundation 3 | import FoundationNetworking 4 | 5 | // `URLSession` in `FoundationNetworking` does not support `async`/`await`. 6 | extension URLSession { 7 | public func data(for request: URLRequest, delegate: (any URLSessionTaskDelegate)? = nil) async throws -> (Data, URLResponse) { 8 | try await withCheckedThrowingContinuation { continuation in 9 | let task = dataTask(with: request) { data, res, err in 10 | if let err { 11 | return continuation.resume(throwing: err) 12 | } 13 | if let data, 14 | let res { 15 | return continuation.resume(returning: (data, res)) 16 | } 17 | } 18 | // task.delegate = delegate 19 | task.resume() 20 | } 21 | } 22 | 23 | public func data(from url: URL, delegate: (any URLSessionTaskDelegate)? = nil) async throws -> (Data, URLResponse) { 24 | try await data(for: URLRequest(url: url), delegate: delegate) 25 | } 26 | 27 | public func upload(for request: URLRequest, fromFile fileURL: URL, delegate: (any URLSessionTaskDelegate)? = nil) async throws -> (Data, URLResponse) { 28 | try await withCheckedThrowingContinuation { continuation in 29 | let task = uploadTask(with: request, fromFile: fileURL) { data, res, err in 30 | if let err { 31 | return continuation.resume(throwing: err) 32 | } 33 | if let data, 34 | let res { 35 | return continuation.resume(returning: (data, res)) 36 | } 37 | } 38 | // task.delegate = delegate 39 | task.resume() 40 | } 41 | } 42 | 43 | public func upload(for request: URLRequest, from bodyData: Data, delegate: (any URLSessionTaskDelegate)? = nil) async throws -> (Data, URLResponse) { 44 | try await withCheckedThrowingContinuation { continuation in 45 | let task = uploadTask(with: request, from: bodyData) { data, res, err in 46 | if let err { 47 | return continuation.resume(throwing: err) 48 | } 49 | if let data, 50 | let res { 51 | return continuation.resume(returning: (data, res)) 52 | } 53 | } 54 | // task.delegate = delegate 55 | task.resume() 56 | } 57 | } 58 | } 59 | #endif 60 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/_Extension/XCTest+Activity.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | func runActivity(named name: String, block: () throws -> Result) rethrows -> Result { 4 | #if os(Linux) 5 | return try block() 6 | #else 7 | return try XCTContext.runActivity(named: name) { _ in try block() } 8 | #endif 9 | } 10 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/_TestFunctions/TestFunction_APIKit.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | #if canImport(APIKit) 5 | import APIKit 6 | 7 | private let session: Session = { 8 | let configuration = URLSessionConfiguration.default 9 | configuration.protocolClasses = [StubURLProtocol.self] 10 | let adapter = URLSessionAdapter(configuration: configuration) 11 | return Session(adapter: adapter) 12 | }() 13 | 14 | extension XCTestCase { 15 | func requestWithAPIKit( 16 | genbaNeko: Data, 17 | denwaNeko: Data, 18 | message: Data, 19 | file: StaticString = #filePath, 20 | line: UInt = #line 21 | ) throws -> URLRequest { 22 | try TestRequest( 23 | genbaNeko: genbaNeko, 24 | denwaNeko: denwaNeko, 25 | message: message, 26 | file: file, 27 | line: line 28 | ).buildURLRequest() 29 | } 30 | 31 | func uploadWithAPIKit( 32 | genbaNeko: Data, 33 | denwaNeko: Data, 34 | message: Data, 35 | retryCount: UInt = 3, 36 | file: StaticString = #filePath, 37 | line: UInt = #line 38 | ) throws -> TestEntity { 39 | let exp = expectation(description: "response") 40 | let request = TestRequest( 41 | genbaNeko: genbaNeko, 42 | denwaNeko: denwaNeko, 43 | message: message, 44 | file: file, 45 | line: line 46 | ) 47 | var result: Result! 48 | session.send(request, callbackQueue: nil) { 49 | result = $0 50 | exp.fulfill() 51 | } 52 | 53 | wait(for: [exp], timeout: 10) 54 | 55 | switch result! { 56 | case let .success(response): 57 | return response 58 | case let .failure(error): 59 | if retryCount > 0 { 60 | print("retry: \(retryCount)") 61 | return try uploadWithAPIKit(genbaNeko: genbaNeko, denwaNeko: denwaNeko, message: message, retryCount: retryCount - 1, file: file, line: line) 62 | } 63 | throw error 64 | } 65 | } 66 | } 67 | 68 | private struct TestRequest: APIKit.Request { 69 | typealias Response = TestEntity 70 | 71 | var baseURL: URL { URL(string: "https://localhost")! } 72 | var path: String { "/upload" } 73 | var method: APIKit.HTTPMethod { .post } 74 | 75 | var genbaNeko: Data 76 | var denwaNeko: Data 77 | var message: Data 78 | 79 | var file: StaticString 80 | var line: UInt 81 | 82 | var bodyParameters: (any BodyParameters)? { 83 | let parts: [MultipartFormDataBodyParameters.Part] = [ 84 | .init( 85 | data: genbaNeko, 86 | name: "genbaNeko", 87 | mimeType: "genbaNeko.jpeg", 88 | fileName: "image/jpeg" 89 | ), 90 | .init( 91 | data: denwaNeko, 92 | name: "denwaNeko", 93 | mimeType: "denwaNeko.jpeg", 94 | fileName: "image/jpeg" 95 | ), 96 | .init(data: message, name: "message"), 97 | ] 98 | return MultipartFormDataBodyParameters(parts: parts) 99 | } 100 | 101 | func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response { 102 | XCTAssertEqual(urlResponse.statusCode, 200, file: file, line: line) 103 | switch object { 104 | case let data as Data: 105 | let decoder = JSONDecoder() 106 | decoder.keyDecodingStrategy = .convertFromSnakeCase 107 | return try decoder.decode(Response.self, from: data) 108 | case let dic as [String: Any]: 109 | guard let status = dic["status"] as? Int else { 110 | throw ResponseError.unexpectedObject(object) 111 | } 112 | return Response(status: status, error: dic["error"] as? String) 113 | default: 114 | throw ResponseError.unexpectedObject(object) 115 | } 116 | } 117 | } 118 | #endif 119 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/_TestFunctions/TestFunction_Alamofire.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | #if canImport(Alamofire) 5 | import Alamofire 6 | 7 | private let session: Session = { 8 | let configuration = URLSessionConfiguration.ephemeral 9 | configuration.protocolClasses = [StubURLProtocol.self] 10 | return Session(configuration: configuration) 11 | }() 12 | 13 | extension XCTestCase { 14 | func uploadWithAlamoFire( 15 | genbaNeko: Data, 16 | denwaNeko: Data, 17 | message: Data, 18 | retryCount: UInt = 3, 19 | file: StaticString = #filePath, 20 | line: UInt = #line 21 | ) throws -> TestEntity? { 22 | let exp = expectation(description: "response") 23 | let task = session.upload( 24 | multipartFormData: { formData in 25 | formData.append( 26 | genbaNeko, 27 | withName: "genbaNeko", 28 | fileName: "genbaNeko.jpeg", 29 | mimeType: "image/jpeg" 30 | ) 31 | formData.append( 32 | denwaNeko, 33 | withName: "denwaNeko", 34 | fileName: "denwaNeko.jpeg", 35 | mimeType: "image/jpeg" 36 | ) 37 | formData.append(message, withName: "message") 38 | }, 39 | to: "https://localhost/upload", 40 | interceptor: Interceptor() 41 | ) 42 | var response: AFDataResponse! 43 | let decoder = JSONDecoder() 44 | decoder.keyDecodingStrategy = .convertFromSnakeCase 45 | task.responseDecodable(of: TestEntity.self, decoder: decoder) { 46 | response = $0 47 | exp.fulfill() 48 | } 49 | wait(for: [exp], timeout: timeoutInterval) 50 | 51 | XCTAssertNotNil(response, file: file, line: line) 52 | XCTAssertEqual(response?.response?.statusCode, 200, file: file, line: line) 53 | switch response.result { 54 | case let .success(entity): return entity 55 | case let .failure(error): throw error 56 | } 57 | } 58 | 59 | func uploadWithAlamoFireConcurrency( 60 | genbaNeko: Data, 61 | denwaNeko: Data, 62 | message: Data, 63 | retryCount: UInt = 3, 64 | file: StaticString = #filePath, 65 | line: UInt = #line 66 | ) async throws -> TestEntity { 67 | let decoder = JSONDecoder() 68 | decoder.keyDecodingStrategy = .convertFromSnakeCase 69 | return try await session.upload( 70 | multipartFormData: { formData in 71 | formData.append( 72 | genbaNeko, 73 | withName: "genbaNeko", 74 | fileName: "genbaNeko.jpeg", 75 | mimeType: "image/jpeg" 76 | ) 77 | formData.append( 78 | denwaNeko, 79 | withName: "denwaNeko", 80 | fileName: "denwaNeko.jpeg", 81 | mimeType: "image/jpeg" 82 | ) 83 | formData.append(message, withName: "message") 84 | }, 85 | to: "https://localhost/upload", 86 | interceptor: Interceptor() 87 | ) 88 | .serializingDecodable(TestEntity.self, decoder: decoder) 89 | .value 90 | } 91 | } 92 | 93 | private class Interceptor: RequestInterceptor { 94 | private let lock = NSLock() 95 | 96 | func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) { 97 | lock.lock(); defer { lock.unlock() } 98 | 99 | if request.retryCount < 3 { 100 | print("retry: \(request.retryCount)") 101 | completion(.retry) 102 | } else { 103 | completion(.doNotRetry) 104 | } 105 | } 106 | } 107 | #endif 108 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/_TestFunctions/TestFunction_URLSession.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | import XCTest 6 | 7 | private let session: URLSession = { 8 | let configuration = URLSessionConfiguration.ephemeral 9 | configuration.protocolClasses = [StubURLProtocol.self] 10 | return URLSession(configuration: configuration) 11 | }() 12 | 13 | extension XCTestCase { 14 | func uploadURLSessionData( 15 | genbaNeko: Data, 16 | denwaNeko: Data, 17 | message: Data, 18 | retryCount: UInt = 3, 19 | file: StaticString = #filePath, 20 | line: UInt = #line 21 | ) async throws -> TestEntity { 22 | let request = createRequest(genbaNeko: genbaNeko, denwaNeko: denwaNeko, message: message) 23 | do { 24 | let (data, _) = try await session.data(for: request) 25 | return try JSONDecoder().decode(TestEntity.self, from: data) 26 | } catch { 27 | guard retryCount > 0 else { throw error } 28 | return try await uploadURLSessionData( 29 | genbaNeko: genbaNeko, 30 | denwaNeko: denwaNeko, 31 | message: message, 32 | retryCount: retryCount - 1, 33 | file: file, 34 | line: line 35 | ) 36 | } 37 | } 38 | 39 | func uploadURLSessionUpload( 40 | genbaNeko: Data, 41 | denwaNeko: Data, 42 | message: Data, 43 | retryCount: UInt = 3, 44 | file: StaticString = #filePath, 45 | line: UInt = #line 46 | ) async throws -> TestEntity { 47 | let boundary = "YoWatanabe0417" 48 | var request = URLRequest(url: URL(string: "https://localhost/upload")!) 49 | request.httpMethod = "POST" 50 | request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") 51 | let requestBody = createBody( 52 | boundary: boundary, 53 | genbaNeko: genbaNeko, 54 | denwaNeko: denwaNeko, 55 | message: message 56 | ) 57 | do { 58 | let (data, _) = try await session.upload(for: request, from: requestBody) 59 | return try JSONDecoder().decode(TestEntity.self, from: data) 60 | } catch { 61 | guard retryCount > 0 else { throw error } 62 | return try await uploadURLSessionUpload( 63 | genbaNeko: genbaNeko, 64 | denwaNeko: denwaNeko, 65 | message: message, 66 | retryCount: retryCount - 1, 67 | file: file, 68 | line: line 69 | ) 70 | } 71 | } 72 | } 73 | 74 | extension XCTestCase { 75 | func createRequest( 76 | genbaNeko: Data, 77 | denwaNeko: Data, 78 | message: Data, 79 | file: StaticString = #filePath, 80 | line: UInt = #line 81 | ) -> URLRequest { 82 | let boundary = "YoWatanabe0417" 83 | var request = URLRequest(url: URL(string: "https://localhost/upload")!) 84 | request.httpMethod = "POST" 85 | request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") 86 | request.httpBody = createBody( 87 | boundary: boundary, 88 | genbaNeko: genbaNeko, 89 | denwaNeko: denwaNeko, 90 | message: message 91 | ) 92 | return request 93 | } 94 | } 95 | 96 | private func createBody( 97 | boundary: String, 98 | genbaNeko: Data, 99 | denwaNeko: Data, 100 | message: Data 101 | ) -> Data { 102 | [ 103 | Data("--\(boundary)\r\n".utf8), 104 | Data("Content-Disposition: form-data; name=\"genbaNeko\"; filename=\"genbaNeko\"\r\n".utf8), 105 | Data("Content-Type: image/jpeg\r\n".utf8), 106 | Data("\r\n".utf8), 107 | genbaNeko, 108 | Data("\r\n".utf8), 109 | Data("--\(boundary)\r\n".utf8), 110 | Data("Content-Disposition: form-data; name=\"denwaNeko\"; filename=\"denwaNeko\"\r\n".utf8), 111 | Data("Content-Type: image/jpeg\r\n".utf8), 112 | Data("\r\n".utf8), 113 | denwaNeko, 114 | Data("\r\n".utf8), 115 | Data("--\(boundary)\r\n".utf8), 116 | Data("Content-Disposition: form-data; name=\"message\"\r\n".utf8), 117 | Data("\r\n".utf8), 118 | message, 119 | Data("\r\n".utf8), 120 | Data("--\(boundary)--\r\n".utf8), 121 | ].reduce(Data(), +) 122 | } 123 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/_Util/Constants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let timeoutInterval: TimeInterval = 20 4 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/_Util/LinuxImage.swift: -------------------------------------------------------------------------------- 1 | #if os(Linux) 2 | import Foundation 3 | 4 | struct Image: Hashable { 5 | let data: Data 6 | 7 | init?(data: Data) { 8 | guard !data.isEmpty, 9 | data.prefix(2) == Data([0xFF, 0xD8]), // SOI marker 10 | data.suffix(2) == Data([0xFF, 0xD9]) // EOI marker 11 | else { 12 | print("Given data is not image.") 13 | return nil 14 | } 15 | if data.prefix(11).dropFirst(2) == Data([0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00]) { 16 | // JPEG 17 | } else { 18 | print("Given data is not an expected image format [jpg, png].") 19 | return nil 20 | } 21 | self.data = data 22 | } 23 | } 24 | #endif 25 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/_Util/StubURLProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | final class StubURLProtocol: URLProtocol { 7 | typealias RequestHandler = (URLRequest) throws -> (Data?, HTTPURLResponse) 8 | 9 | static var requestHandler: RequestHandler? 10 | 11 | override class func canInit(with request: URLRequest) -> Bool { 12 | true 13 | } 14 | 15 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 16 | request 17 | } 18 | 19 | override func startLoading() { 20 | guard let requestHandler = Self.requestHandler else { return } 21 | do { 22 | let (data, response) = try requestHandler(request) 23 | client?.urlProtocol( 24 | self, 25 | didReceive: response, 26 | cacheStoragePolicy: .notAllowed 27 | ) 28 | if let data { 29 | client?.urlProtocol(self, didLoad: data) 30 | } 31 | client?.urlProtocolDidFinishLoading(self) 32 | } catch { 33 | client?.urlProtocol(self, didFailWithError: error) 34 | } 35 | } 36 | 37 | override func stopLoading() { 38 | // no-op 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/_Util/TestEntity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct TestEntity: Codable { 4 | let status: Int 5 | let error: String? 6 | } 7 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/_Util/TestResource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum TestResource { 4 | // swiftlint:disable:next line_length 5 | private static let genbaNekoBase64 = #"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"# 6 | 7 | // swiftlint:disable:next line_length 8 | private static let denwaNekoBase64 = #"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"# 9 | } 10 | 11 | extension TestResource { 12 | static var genbaNeko: Data { Data(base64Encoded: genbaNekoBase64, options: .ignoreUnknownCharacters)! } 13 | static var denwaNeko: Data { Data(base64Encoded: denwaNekoBase64, options: .ignoreUnknownCharacters)! } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/MultipartFormDataParserTests/_Util/TestStub.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | #if canImport(UIKit) 7 | import UIKit 8 | typealias Image = UIImage 9 | #elseif canImport(Cocoa) 10 | import Cocoa 11 | typealias Image = NSImage 12 | #endif 13 | 14 | import MultipartFormDataParser 15 | 16 | func stubForUpload() { 17 | StubURLProtocol.requestHandler = uploadTestStubResponse 18 | } 19 | 20 | func clearStubs() { 21 | StubURLProtocol.requestHandler = nil 22 | } 23 | 24 | // swiftlint:disable:next closure_body_length 25 | private let uploadTestStubResponse: StubURLProtocol.RequestHandler = { request in 26 | let errorResponse = { (message: String) -> (Data?, HTTPURLResponse) in 27 | ( 28 | Data(#"{"status": 403, "error": "\#(message)"}"#.utf8), 29 | HTTPURLResponse( 30 | url: request.url!, 31 | statusCode: 403, 32 | httpVersion: "HTTP/2", 33 | headerFields: ["Content-Type": "application/json"] 34 | )! 35 | ) 36 | } 37 | do { 38 | let data = try MultipartFormData.parse(from: request) 39 | guard let genbaNeko = data.element(forName: "genbaNeko") else { return errorResponse("genbaNeko") } 40 | guard let denwaNeko = data.element(forName: "denwaNeko") else { return errorResponse("denwaNeko") } 41 | guard let message = data.element(forName: "message") else { return errorResponse("message") } 42 | guard let _ = Image(data: genbaNeko.data) else { return errorResponse("Unexpected genbaNeko") } 43 | guard let _ = Image(data: denwaNeko.data) else { return errorResponse("Unexpected denwaNeko") } 44 | guard message.string == "Hello world!" else { return errorResponse("Unexpected message: \(message)") } 45 | return ( 46 | Data(#"{"status": 200}"#.utf8), 47 | HTTPURLResponse( 48 | url: request.url!, 49 | statusCode: 200, 50 | httpVersion: "HTTP/2", 51 | headerFields: ["Content-Type": "application/json"] 52 | )! 53 | ) 54 | } catch { 55 | return errorResponse(error.localizedDescription) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | set -eu 4 | 5 | PROJECT_NAME=$1 6 | TAG=$2 7 | 8 | DEBUG=0 9 | 10 | if ! type "gh" > /dev/null; then 11 | echo '\e[33m`gh` not found. Install\e[m' 12 | brew install gh 13 | fi 14 | 15 | cd $(git rev-parse --show-toplevel) 16 | 17 | if [ `git symbolic-ref --short HEAD` != 'main' ]; then 18 | echo '\e[33mRelease job is enabled only in main. Run in debug mode\e[m' 19 | DEBUG=1 20 | fi 21 | 22 | echo "${TAG}" | grep -wE '([0-9]+)\.([0-9]+)\.([0-9]+)' > /dev/null 2>&1 23 | if [ $? -ne 0 ]; then 24 | echo "Invalid version format: \"${TAG}\"" 25 | exit 1 26 | fi 27 | 28 | LOCAL_CHANGES=`git diff --name-only HEAD` 29 | if [ "$LOCAL_CHANGES" = 'Makefile' ]; then 30 | MAKEFILE_DIFF="$(git diff -U0 Makefile | grep '^[+-]' | grep -Ev '^(--- a/|\+\+\+ b/)')" 31 | if [ "$(echo $MAKEFILE_DIFF | grep -Ev '^[+-]ver = [0-9]*\.[0-9]*\.[0-9]*$')" != '' ]; then 32 | echo '\e[31m[Error] There are some local changes.\e[m' 33 | exit 1 34 | fi 35 | elif [ "$LOCAL_CHANGES" != '' ]; then 36 | echo '\e[31m[Error] There are some local changes.\e[m' 37 | exit 1 38 | fi 39 | 40 | # Validate 41 | if [ "$(git fetch --tags && git tag | grep "${TAG}")" != '' ]; then 42 | echo "\e[31m[Error] '${TAG}' tag already exists.\e[m" 43 | exit 1 44 | fi 45 | 46 | sed -i '' -E "s/(\.package\(url: \".*${PROJECT_NAME}\.git\", from: \").*(\"\),?)/\1${TAG}\2/g" README.md 47 | sed -i '' -E "s/(let isDevelop = )(true|false)/\1false/" Package.swift 48 | 49 | # Podspec 50 | MAC_OS_VERSION="$(cat Package.swift | grep '.macOS(.v' | sed -E "s/ *\.macOS\(\.v([0-9_]*)\),?/\1/g" | sed -E "s/_/./g")" 51 | if [[ "$MAC_OS_VERSION" != *"."* ]]; then 52 | MAC_OS_VERSION="${MAC_OS_VERSION}.0" 53 | fi 54 | IOS_VERSION="$(cat Package.swift | grep '.iOS(.v' | sed -E "s/ *\.iOS\(\.v([0-9_]*)\),?/\1/g" | sed -E "s/_/./g")" 55 | if [[ "$IOS_VERSION" != *"."* ]]; then 56 | IOS_VERSION="${IOS_VERSION}.0" 57 | fi 58 | TV_OS_VERSION="$(cat Package.swift | grep '.tvOS(.v' | sed -E "s/ *\.tvOS\(\.v([0-9_]*)\),?/\1/g" | sed -E "s/_/./g")" 59 | if [[ "$TV_OS_VERSION" != *"."* ]]; then 60 | TV_OS_VERSION="${TV_OS_VERSION}.0" 61 | fi 62 | 63 | sed -i '' -E "s/(spec\.version *= )\"([0-9]*\.[0-9]*(\.[0-9]*)?)\"/\1\"${TAG}\"/g" ${PROJECT_NAME}.podspec 64 | sed -i '' -E "s/(spec\.osx\.deployment_target *= )\"([0-9]*\.[0-9]*(\.[0-9]*)?)\"/\1\"${MAC_OS_VERSION}\"/g" ${PROJECT_NAME}.podspec 65 | sed -i '' -E "s/(spec\.ios\.deployment_target *= )\"([0-9]*\.[0-9]*(\.[0-9]*)?)\"/\1\"${IOS_VERSION}\"/g" ${PROJECT_NAME}.podspec 66 | sed -i '' -E "s/(spec\.tvos\.deployment_target *= )\"([0-9]*\.[0-9]*(\.[0-9]*)?)\"/\1\"${TV_OS_VERSION}\"/g" ${PROJECT_NAME}.podspec 67 | 68 | COMMIT_OPTION='' 69 | if [ $DEBUG -ne 0 ]; then 70 | COMMIT_OPTION='--dry-run' 71 | fi 72 | 73 | git commit $COMMIT_OPTION -m "Bump version to ${TAG}" Package.swift Makefile README.md "${PROJECT_NAME}.podspec" 74 | if [ $DEBUG -eq 0 ]; then 75 | git push origin main 76 | gh release create ${TAG} --target main --title ${TAG} --generate-notes 77 | fi 78 | 79 | sed -i '' -E "s/(let isDevelop = )(true|false)/\1true/" Package.swift 80 | git commit $COMMIT_OPTION -m 'switch develop flag to true' Package.swift 81 | if [ $DEBUG -eq 0 ]; then 82 | git push origin main 83 | fi 84 | --------------------------------------------------------------------------------