From ff0704bdb075ee9ce11aea93e564e72522f6f114 Mon Sep 17 00:00:00 2001 From: leogdion Date: Sun, 5 May 2024 12:28:15 -0400 Subject: [PATCH] v1.0.0-alpha.1 --- .github/workflows/AviaryInsights.yml | 227 ++++++++++ .gitignore | 130 ++++++ .hound.yml | 2 + .periphery.yml | 3 + .spi.yml | 4 + .swift-version | 1 + .swiftformat | 7 + .swiftlint.yml | 132 ++++++ LICENSE | 21 + Mintfile | 4 + Package.resolved | 41 ++ Package.swift | 52 +++ README.md | 146 ++++++ Scripts/generate.sh | 3 + Scripts/gh-md-toc | 421 ++++++++++++++++++ Scripts/lint.sh | 47 ++ .../Documentation.docc/AviaryInsights.md | 127 ++++++ Sources/AviaryInsights/Event.swift | 80 ++++ Sources/AviaryInsights/Generated/Client.swift | 110 +++++ Sources/AviaryInsights/Generated/Types.swift | 274 ++++++++++++ Sources/AviaryInsights/Operations.swift | 55 +++ Sources/AviaryInsights/Plausible.swift | 170 +++++++ Sources/AviaryInsights/Revenue.swift | 46 ++ .../AviaryInsightsTests.swift | 82 ++++ Tests/AviaryInsightsTests/Event.swift | 61 +++ Tests/AviaryInsightsTests/MockTransport.swift | 81 ++++ Tests/AviaryInsightsTests/Revenue.swift | 37 ++ codecov.yml | 2 + openapi-generator-config.yaml | 4 + openapi.yaml | 67 +++ project.yml | 13 + 31 files changed, 2450 insertions(+) create mode 100644 .github/workflows/AviaryInsights.yml create mode 100644 .gitignore create mode 100644 .hound.yml create mode 100644 .periphery.yml create mode 100644 .spi.yml create mode 100644 .swift-version create mode 100644 .swiftformat create mode 100644 .swiftlint.yml create mode 100644 LICENSE create mode 100644 Mintfile create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100755 Scripts/generate.sh create mode 100755 Scripts/gh-md-toc create mode 100755 Scripts/lint.sh create mode 100644 Sources/AviaryInsights/Documentation.docc/AviaryInsights.md create mode 100644 Sources/AviaryInsights/Event.swift create mode 100644 Sources/AviaryInsights/Generated/Client.swift create mode 100644 Sources/AviaryInsights/Generated/Types.swift create mode 100644 Sources/AviaryInsights/Operations.swift create mode 100644 Sources/AviaryInsights/Plausible.swift create mode 100644 Sources/AviaryInsights/Revenue.swift create mode 100644 Tests/AviaryInsightsTests/AviaryInsightsTests.swift create mode 100644 Tests/AviaryInsightsTests/Event.swift create mode 100644 Tests/AviaryInsightsTests/MockTransport.swift create mode 100644 Tests/AviaryInsightsTests/Revenue.swift create mode 100644 codecov.yml create mode 100644 openapi-generator-config.yaml create mode 100644 openapi.yaml create mode 100644 project.yml diff --git a/.github/workflows/AviaryInsights.yml b/.github/workflows/AviaryInsights.yml new file mode 100644 index 0000000..762c4c2 --- /dev/null +++ b/.github/workflows/AviaryInsights.yml @@ -0,0 +1,227 @@ +name: macOS +on: + push: + branches-ignore: + - '*WIP' +env: + PACKAGE_NAME: AviaryInsights +jobs: + build-ubuntu: + name: Build on Ubuntu + env: + SWIFT_VER: ${{ matrix.swift-version }} + runs-on: ${{ matrix.runs-on }} + if: "!contains(github.event.head_commit.message, 'ci skip')" + strategy: + matrix: + runs-on: [ubuntu-20.04, ubuntu-22.04] + swift-version: ["5.9", "5.9.2", "5.10"] + steps: + - uses: actions/checkout@v4 + - name: Set Ubuntu Release DOT + run: echo "RELEASE_DOT=$(lsb_release -sr)" >> $GITHUB_ENV + - name: Set Ubuntu Release NUM + run: echo "RELEASE_NUM=${RELEASE_DOT//[-._]/}" >> $GITHUB_ENV + - name: Set Ubuntu Codename + run: echo "RELEASE_NAME=$(lsb_release -sc)" >> $GITHUB_ENV + - name: Cache swift package modules + id: cache-spm-linux + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ runner.os }}-${{ env.RELEASE_DOT }}-${{ env.cache-name }}-${{ matrix.swift-version }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-${{ env.RELEASE_DOT }}-${{ env.cache-name }}-${{ matrix.swift-version }}- + ${{ runner.os }}-${{ env.RELEASE_DOT }}-${{ env.cache-name }}- + - name: Cache swift + id: cache-swift-linux + uses: actions/cache@v4 + env: + cache-name: cache-swift + with: + path: swift-${{ env.SWIFT_VER }}-RELEASE-ubuntu${{ env.RELEASE_DOT }} + key: ${{ runner.os }}-${{ env.cache-name }}-${{ matrix.swift-version }}-${{ env.RELEASE_DOT }} + restore-keys: | + ${{ runner.os }}-${{ env.cache-name }}-${{ matrix.swift-version }}- + - name: Download Swift + if: steps.cache-swift-linux.outputs.cache-hit != 'true' + run: curl -O https://download.swift.org/swift-${SWIFT_VER}-release/ubuntu${RELEASE_NUM}/swift-${SWIFT_VER}-RELEASE/swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}.tar.gz + - name: Extract Swift + if: steps.cache-swift-linux.outputs.cache-hit != 'true' + run: tar xzf swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}.tar.gz + - name: Add Path + run: echo "$GITHUB_WORKSPACE/swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}/usr/bin" >> $GITHUB_PATH + - name: Test + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + flags: swift-${{ matrix.swift-version }},ubuntu-${{ matrix.RELEASE_DOT }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + build-macos: + name: Build on macOS + runs-on: ${{ matrix.os }} + if: "!contains(github.event.head_commit.message, 'ci skip')" + strategy: + matrix: + include: + - xcode: "/Applications/Xcode_15.0.1.app" + os: macos-13 + iOSVersion: "17.0.1" + watchOSVersion: "10.0" + watchName: "Apple Watch Series 9 (41mm)" + iPhoneName: "iPhone 15" + - xcode: "/Applications/Xcode_15.1.app" + os: macos-13 + iOSVersion: "17.2" + watchOSVersion: "10.2" + watchName: "Apple Watch Series 9 (45mm)" + iPhoneName: "iPhone 15 Plus" + - xcode: "/Applications/Xcode_15.2.app" + os: macos-14 + iOSVersion: "17.2" + watchOSVersion: "10.2" + watchName: "Apple Watch Ultra (49mm)" + iPhoneName: "iPhone 15 Pro" + - xcode: "/Applications/Xcode_15.3.app" + os: macos-14 + iOSVersion: "17.4" + watchOSVersion: "10.4" + watchName: "Apple Watch Ultra 2 (49mm)" + iPhoneName: "iPhone 15 Pro Max" + steps: + - uses: actions/checkout@v4 + - name: Cache swift package modules + id: cache-spm-macos + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ matrix.os }}-build-${{ env.cache-name }}-${{ matrix.xcode }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ matrix.os }}-build-${{ env.cache-name }}-${{ matrix.xcode }}- + - name: Cache mint + if: startsWith(matrix.xcode,'/Applications/Xcode_15.3') + id: cache-mint + uses: actions/cache@v4 + env: + cache-name: cache-mint + with: + path: .mint + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('Mintfile') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Set Xcode Name + run: echo "XCODE_NAME=$(basename -- ${{ matrix.xcode }} | sed 's/\.[^.]*$//' | cut -d'_' -f2)" >> $GITHUB_ENV + - name: Setup Xcode + run: sudo xcode-select -s ${{ matrix.xcode }}/Contents/Developer + - name: Install mint + if: startsWith(matrix.xcode,'/Applications/Xcode_15.3') + run: | + brew update + brew install mint + - name: Build + run: swift build + - name: Run Swift Package tests + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-spm + with: + fail-on-empty-output: true + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + files: ${{ join(fromJSON(steps.coverage-files-spm.outputs.files), ',') }} + token: ${{ secrets.CODECOV_TOKEN }} + flags: macOS,${{ env.XCODE_NAME }},${{ matrix.runs-on }} + - name: Clean up spm build directory + run: rm -rf .build + - name: Lint + run: ./scripts/lint.sh + if: startsWith(matrix.xcode,'/Applications/Xcode_15.3') + - name: Run iOS target tests + run: xcodebuild test -scheme ${{ env.PACKAGE_NAME }} -sdk "iphonesimulator" -destination 'platform=iOS Simulator,name=${{ matrix.iPhoneName }},OS=${{ matrix.iOSVersion }}' -enableCodeCoverage YES build test + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-iOS + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files-iOS.outputs.files), ',') }} + flags: iOS,iOS${{ matrix.iOSVersion }},macOS,${{ env.XCODE_NAME }} + - name: Run watchOS target tests + run: xcodebuild test -scheme ${{ env.PACKAGE_NAME }} -sdk "watchsimulator" -destination 'platform=watchOS Simulator,name=${{ matrix.watchName }},OS=${{ matrix.watchOSVersion }}' -enableCodeCoverage YES build test + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-watchOS + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files-watchOS.outputs.files), ',') }} + flags: watchOS,watchOS${{ matrix.watchOSVersion }},macOS,${{ env.XCODE_NAME }} + build-self: + name: Build on Self-Hosting macOS + runs-on: [self-hosted, macOS] + if: github.event.repository.owner.login == github.event.organization.login && !contains(github.event.head_commit.message, 'ci skip') + steps: + - uses: actions/checkout@v4 + - name: Cache swift package modules + id: cache-spm-macos + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Cache mint + id: cache-mint + uses: actions/cache@v4 + env: + cache-name: cache-mint + with: + path: .mint + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('Mintfile') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Build + run: swift build + - name: Run Swift Package tests + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + with: + fail-on-empty-output: true + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: macOS,${{ env.XCODE_NAME }} + - name: Clean up spm build directory + run: rm -rf .build + - name: Lint + run: ./scripts/lint.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd44408 --- /dev/null +++ b/.gitignore @@ -0,0 +1,130 @@ +# Created by https://www.toptal.com/developers/gitignore/api/swift,swiftpm,swiftpackagemanager,xcode,macos +# Edit at https://www.toptal.com/developers/gitignore?templates=swift,swiftpm,swiftpackagemanager,xcode,macos + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +*.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +.mint +Output \ No newline at end of file diff --git a/.hound.yml b/.hound.yml new file mode 100644 index 0000000..6941f63 --- /dev/null +++ b/.hound.yml @@ -0,0 +1,2 @@ +swiftlint: + config_file: .swiftlint.yml diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..b207980 --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,3 @@ +retain_public: true +index_exclude: + - "Sources/**/Generated/*.swift" \ No newline at end of file diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..f92b102 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [AviaryInsights] diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..95ee81a --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +5.9 diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..df6df66 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,7 @@ +--indent 2 +--header "\n .*?\.swift\n AviaryInsights\n\n Created by Leo Dion.\n Copyright © {year} BrightDigit.\n\n Permission is hereby granted, free of charge, to any person\n obtaining a copy of this software and associated documentation\n files (the “Software”), to deal in the Software without\n restriction, including without limitation the rights to use,\n copy, modify, merge, publish, distribute, sublicense, and/or\n sell copies of the Software, and to permit persons to whom the\n Software is furnished to do so, subject to the following\n conditions:\n \n The above copyright notice and this permission notice shall be\n included in all copies or substantial portions of the Software.\n\n THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,\n EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\n OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\n NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\n HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\n WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\n OTHER DEALINGS IN THE SOFTWARE.\n" +--commas inline +--disable wrapMultilineStatementBraces, redundantInternal,redundantSelf,wrapMultilineStatementBraces,genericExtensions +--extensionacl on-declarations +--decimalgrouping 3,4 +--exclude .build, DerivedData, .swiftpm, Sources/AviaryInsights/Generated diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..3f760de --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,132 @@ +opt_in_rules: + - array_init + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - expiring_todo + - explicit_acl + - explicit_init + - explicit_top_level_acl + # - fallthrough + - fatal_error_message + - file_name + - file_name_no_space + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping +# - function_default_parameter_at_end + - ibinspectable_in_extension + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - indentation_width + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - nimble_operator + - nslocalizedstring_key + - nslocalizedstring_require_bundle + - number_separator + - object_literal + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool +# - trailing_closure + - type_contents_order + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition +analyzer_rules: + - unused_import + - unused_declaration +cyclomatic_complexity: + - 6 + - 12 +type_body_length: + - 100 + - 200 +file_length: + warning: 215 + error: 300 +function_body_length: + - 18 + - 40 +function_parameter_count: 8 +line_length: + - 108 + - 200 +closure_body_length: + - 50 + - 60 +identifier_name: + excluded: + - id + - no +excluded: + - DerivedData + - .build + - .swiftpm + - Sources/AviaryInsights/Generated +indentation_width: + indentation_width: 2 +file_name: + severity: error + excluded: ["Operations.swift"] +fatal_error_message: + severity: error +disabled_rules: + - nesting + - implicit_getter + - switch_case_alignment \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..575c376 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 BrightDigit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Mintfile b/Mintfile new file mode 100644 index 0000000..cd61dec --- /dev/null +++ b/Mintfile @@ -0,0 +1,4 @@ +nicklockwood/SwiftFormat@0.53.5 +realm/SwiftLint@0.54.0 +apple/swift-openapi-generator@1.2.1 +peripheryapp/periphery@2.18.0 \ No newline at end of file diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..74a5e53 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,41 @@ +{ + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "12358d55a3824bd5fed310b999ea8cf83a9a1a65", + "version" : "1.0.3" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "9a8291fa2f90cc7296f2393a99bb4824ee34f869", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "6efbfda5276bbbc8b4fec5d744f0ecd8c784eb47", + "version" : "1.0.1" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..003a9b3 --- /dev/null +++ b/Package.swift @@ -0,0 +1,52 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +// swiftlint:disable:next explicit_top_level_acl explicit_acl +let package = Package( + name: "AviaryInsights", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .macCatalyst(.v13), + .tvOS(.v13), + .visionOS(.v1), + .watchOS(.v6) + ], + products: [ + .library( + name: "AviaryInsights", + targets: ["AviaryInsights"] + ) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0") + ], + targets: [ + .target( + name: "AviaryInsights", + dependencies: [ + .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime") + ], + swiftSettings: [ + SwiftSetting.enableUpcomingFeature("BareSlashRegexLiterals"), + SwiftSetting.enableUpcomingFeature("ConciseMagicFile"), + SwiftSetting.enableUpcomingFeature("ExistentialAny"), + SwiftSetting.enableUpcomingFeature("ForwardTrailingClosures"), + SwiftSetting.enableUpcomingFeature("ImplicitOpenExistentials"), + SwiftSetting.enableUpcomingFeature("DisableOutwardActorInference"), + SwiftSetting.enableExperimentalFeature("StrictConcurrency"), + SwiftSetting.unsafeFlags(["-warn-concurrency", "-enable-actor-data-race-checks"]) + ] + ), + .testTarget( + name: "AviaryInsightsTests", + dependencies: [ + "AviaryInsights", + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime") + ] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e95d455 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# AviaryInsights + +Easy to use Swift Package for recording pageviews and custom events for Plausible. + +[![SwiftPM](https://img.shields.io/badge/SPM-Linux%20%7C%20iOS%20%7C%20macOS%20%7C%20watchOS%20%7C%20tvOS-success?logo=swift)](https://swift.org) +[![Twitter](https://img.shields.io/badge/twitter-@brightdigit-blue.svg?style=flat)](http://twitter.com/brightdigit) +![GitHub](https://img.shields.io/github/license/brightdigit/AviaryInsights) +![GitHub issues](https://img.shields.io/github/issues/brightdigit/AviaryInsights) +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/brightdigit/AviaryInsights/AviaryInsights.yml?label=actions&logo=github&?branch=main) + +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FAviaryInsights%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/brightdigit/AviaryInsights) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FAviaryInsights%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/brightdigit/AviaryInsights) + +[![Codecov](https://img.shields.io/codecov/c/github/brightdigit/AviaryInsights)](https://codecov.io/gh/brightdigit/AviaryInsights) +[![CodeFactor Grade](https://img.shields.io/codefactor/grade/github/brightdigit/AviaryInsights)](https://www.codefactor.io/repository/github/brightdigit/AviaryInsights) +[![codebeat badge](https://codebeat.co/badges/94a8313d-2215-4ef6-8690-ab7b3e06369c)](https://codebeat.co/projects/github-com-brightdigit-mistkit-main) +[![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/brightdigit/AviaryInsights)](https://codeclimate.com/github/brightdigit/AviaryInsights) +[![Code Climate technical debt](https://img.shields.io/codeclimate/tech-debt/brightdigit/AviaryInsights?label=debt)](https://codeclimate.com/github/brightdigit/AviaryInsights) +[![Code Climate issues](https://img.shields.io/codeclimate/issues/brightdigit/AviaryInsights)](https://codeclimate.com/github/brightdigit/AviaryInsights) +[![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com) + +Table of Contents +================= + +* [Features](#features) +* [Requirements](#requirements) +* [Installation](#installation) +* [Usage](#usage) + * [Sending an Event](#sending-an-event) + * [Asynchronous Throwing Method](#asynchronous-throwing-method) + * [Synchronous Method](#synchronous-method) +* [Contributing](#contributing) +* [License](#license) + +## Features + +Plausible provides simple and meaningful insights into your website's traffic without invading the privacy of your visitors. However, integrating Plausible into a Swift application can be complex and time-consuming. AviaryInsights simplifies this process, allowing you to focus on building your application while still gaining the valuable insights that Plausible provides. + +- **Event tracking** Define and track custom events in your application. +- **Revenue tracking** Track revenue data associated with events. +- **Plausible API integration** Send your events to the Plausible API for further analysis. + +## Requirements + +**Apple Platforms** + +- Xcode 15 or later +- Swift 5.9 or later +- iOS 13 / watchOS 6 / tvOS 13 / visionOS 1 / macCatalyst 13 / macOS 10.15 or later deployment targets + +**Linux** + +- Ubuntu 20.04 or later +- Swift 5.9 or later + +## Installation + +To add the AviaryInsights package to your Xcode project, select File > Swift Packages > Add Package Dependency and enter the repository URL. + +Using Swift Package Manager add the repository url: + +``` +https://github.com/brightdigit/README.git +``` + +## Usage + +```swift +import AviaryInsights + +// Initialize the client with your bundle identifier as the domain +let plausible = Plausible(domain: "com.example.yourApp") + +// Define an event +let event = Event(url: "app://localhost/login") + +// Send the event +plausible.send(event: event) +``` + +### `Plausible` Client + +`Plausible` is a client for interacting with the Plausible API. It is initialized with a domain, which is typically your app's bundle identifier. The `Plausible` client is used to send events to the Plausible API for tracking and analysis. + +To construct a `Plausible` instance, you need to provide a domain. The domain is a string that identifies your application, typically the bundle identifier of your app. + +```swift +let plausible = Plausible(domain: "com.example.yourApp") +``` + +By default `Plausible` uses a `URLSessionTransport`, however you can use alternatives such as AsyncClient. + +### Sending an `Event` + +`Event` represents an event in your system. An event has a name, and optionally, a domain, URL, referrer, custom properties (`props`), and revenue information. You can create an `Event` instance and send it using the `Plausible` client. + +To construct an `Event`, you need to provide at least a name. The name is a string that identifies the event you want to track. Optionally, you can also provide: + +- **`name`** string that represents the name of the event. _Default_ is **pageview**. +- **`url`** string that represents the URL where the event occurred. For an app you may wish to use a app url such as `app://localhost/login`. +- `domain` _optional_ string that identifies the domain in which the event occurred. Overrides whatever was set in the `Plausible` instance. +- `referrer` _optional_ string that represents the URL of the referrer +- `props` _optional_ dictionary of custom properties associated with the event. +- `revenue` _optional_ `Revenue` instance that represents the revenue data associated with the event + +```swift +let event = Event + name: "eventName", + domain: "domain", + url: "url", + referrer: "referrer", + props: ["key": "value"], + revenue: Revenue( + currencyCode: "USD", + amount: 100 + ) +) +``` + +AviaryInsights provides two ways to send events to the Plausible API: + +#### Asynchronous Throwing Method + +This method sends an event to the Plausible API and throws an error if the operation fails. This is useful when you want to handle errors in your own way. Here's an example: + +```swift +do { + try await plausible.postEvent(event) +} catch { + print("Failed to post event: \(error)") +} +``` + +#### Synchronous Method + +This method sends an event to the Plausible API in the background and ignores any errors that occur. This is useful when you don't need to handle errors and want to fire-and-forget the event. Here's an example: + +```swift +plausible.postEvent(event) +``` + +In both cases, `event` is an instance of `Event` that you want to send to the Plausible API. + +## License + +AviaryInsights is available under the MIT license. See the [LICENSE](LICENSE) file for more info. diff --git a/Scripts/generate.sh b/Scripts/generate.sh new file mode 100755 index 0000000..b88e2dc --- /dev/null +++ b/Scripts/generate.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +swift run swift-openapi-generator generate --output-directory Sources/Ngrokit/Generated --config openapi-generator-config.yaml openapi.yaml diff --git a/Scripts/gh-md-toc b/Scripts/gh-md-toc new file mode 100755 index 0000000..03b5ddd --- /dev/null +++ b/Scripts/gh-md-toc @@ -0,0 +1,421 @@ +#!/usr/bin/env bash + +# +# Steps: +# +# 1. Download corresponding html file for some README.md: +# curl -s $1 +# +# 2. Discard rows where no substring 'user-content-' (github's markup): +# awk '/user-content-/ { ... +# +# 3.1 Get last number in each row like ' ... sitemap.js.*<\/h/)+2, RLENGTH-5) +# +# 5. Find anchor and insert it inside "(...)": +# substr($0, match($0, "href=\"[^\"]+?\" ")+6, RLENGTH-8) +# + +gh_toc_version="0.10.0" + +gh_user_agent="gh-md-toc v$gh_toc_version" + +# +# Download rendered into html README.md by its url. +# +# +gh_toc_load() { + local gh_url=$1 + + if type curl &>/dev/null; then + curl --user-agent "$gh_user_agent" -s "$gh_url" + elif type wget &>/dev/null; then + wget --user-agent="$gh_user_agent" -qO- "$gh_url" + else + echo "Please, install 'curl' or 'wget' and try again." + exit 1 + fi +} + +# +# Converts local md file into html by GitHub +# +# -> curl -X POST --data '{"text": "Hello world github/linguist#1 **cool**, and #1!"}' https://api.github.com/markdown +#

Hello world github/linguist#1 cool, and #1!

'" +gh_toc_md2html() { + local gh_file_md=$1 + local skip_header=$2 + + URL=https://api.github.com/markdown/raw + + if [ -n "$GH_TOC_TOKEN" ]; then + TOKEN=$GH_TOC_TOKEN + else + TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" + if [ -f "$TOKEN_FILE" ]; then + TOKEN="$(cat "$TOKEN_FILE")" + fi + fi + if [ -n "${TOKEN}" ]; then + AUTHORIZATION="Authorization: token ${TOKEN}" + fi + + local gh_tmp_file_md=$gh_file_md + if [ "$skip_header" = "yes" ]; then + if grep -Fxq "" "$gh_src"; then + # cut everything before the toc + gh_tmp_file_md=$gh_file_md~~ + sed '1,//d' "$gh_file_md" > "$gh_tmp_file_md" + fi + fi + + # echo $URL 1>&2 + OUTPUT=$(curl -s \ + --user-agent "$gh_user_agent" \ + --data-binary @"$gh_tmp_file_md" \ + -H "Content-Type:text/plain" \ + -H "$AUTHORIZATION" \ + "$URL") + + rm -f "${gh_file_md}~~" + + if [ "$?" != "0" ]; then + echo "XXNetworkErrorXX" + fi + if [ "$(echo "${OUTPUT}" | awk '/API rate limit exceeded/')" != "" ]; then + echo "XXRateLimitXX" + else + echo "${OUTPUT}" + fi +} + + +# +# Is passed string url +# +gh_is_url() { + case $1 in + https* | http*) + echo "yes";; + *) + echo "no";; + esac +} + +# +# TOC generator +# +gh_toc(){ + local gh_src=$1 + local gh_src_copy=$1 + local gh_ttl_docs=$2 + local need_replace=$3 + local no_backup=$4 + local no_footer=$5 + local indent=$6 + local skip_header=$7 + + if [ "$gh_src" = "" ]; then + echo "Please, enter URL or local path for a README.md" + exit 1 + fi + + + # Show "TOC" string only if working with one document + if [ "$gh_ttl_docs" = "1" ]; then + + echo "Table of Contents" + echo "=================" + echo "" + gh_src_copy="" + + fi + + if [ "$(gh_is_url "$gh_src")" == "yes" ]; then + gh_toc_load "$gh_src" | gh_toc_grab "$gh_src_copy" "$indent" + if [ "${PIPESTATUS[0]}" != "0" ]; then + echo "Could not load remote document." + echo "Please check your url or network connectivity" + exit 1 + fi + if [ "$need_replace" = "yes" ]; then + echo + echo "!! '$gh_src' is not a local file" + echo "!! Can't insert the TOC into it." + echo + fi + else + local rawhtml + rawhtml=$(gh_toc_md2html "$gh_src" "$skip_header") + if [ "$rawhtml" == "XXNetworkErrorXX" ]; then + echo "Parsing local markdown file requires access to github API" + echo "Please make sure curl is installed and check your network connectivity" + exit 1 + fi + if [ "$rawhtml" == "XXRateLimitXX" ]; then + echo "Parsing local markdown file requires access to github API" + echo "Error: You exceeded the hourly limit. See: https://developer.github.com/v3/#rate-limiting" + TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" + echo "or place GitHub auth token here: ${TOKEN_FILE}" + exit 1 + fi + local toc + toc=`echo "$rawhtml" | gh_toc_grab "$gh_src_copy" "$indent"` + echo "$toc" + if [ "$need_replace" = "yes" ]; then + if grep -Fxq "" "$gh_src" && grep -Fxq "" "$gh_src"; then + echo "Found markers" + else + echo "You don't have or in your file...exiting" + exit 1 + fi + local ts="<\!--ts-->" + local te="<\!--te-->" + local dt + dt=$(date +'%F_%H%M%S') + local ext=".orig.${dt}" + local toc_path="${gh_src}.toc.${dt}" + local toc_createdby="" + local toc_footer + toc_footer="" + # http://fahdshariff.blogspot.ru/2012/12/sed-mutli-line-replacement-between-two.html + # clear old TOC + sed -i"${ext}" "/${ts}/,/${te}/{//!d;}" "$gh_src" + # create toc file + echo "${toc}" > "${toc_path}" + if [ "${no_footer}" != "yes" ]; then + echo -e "\n${toc_createdby}\n${toc_footer}\n" >> "$toc_path" + fi + + # insert toc file + if ! sed --version > /dev/null 2>&1; then + sed -i "" "/${ts}/r ${toc_path}" "$gh_src" + else + sed -i "/${ts}/r ${toc_path}" "$gh_src" + fi + echo + if [ "${no_backup}" = "yes" ]; then + rm "$toc_path" "$gh_src$ext" + fi + echo "!! TOC was added into: '$gh_src'" + if [ -z "${no_backup}" ]; then + echo "!! Origin version of the file: '${gh_src}${ext}'" + echo "!! TOC added into a separate file: '${toc_path}'" + fi + echo + fi + fi +} + +# +# Grabber of the TOC from rendered html +# +# $1 - a source url of document. +# It's need if TOC is generated for multiple documents. +# $2 - number of spaces used to indent. +# +gh_toc_grab() { + + href_regex="/href=\"[^\"]+?\"/" + common_awk_script=' + modified_href = "" + split(href, chars, "") + for (i=1;i <= length(href); i++) { + c = chars[i] + res = "" + if (c == "+") { + res = " " + } else { + if (c == "%") { + res = "\\x" + } else { + res = c "" + } + } + modified_href = modified_href res + } + print sprintf("%*s", (level-1)*'"$2"', "") "* [" text "](" gh_url modified_href ")" + ' + if [ "`uname -s`" == "OS/390" ]; then + grepcmd="pcregrep -o" + echoargs="" + awkscript='{ + level = substr($0, 3, 1) + text = substr($0, match($0, /<\/span><\/a>[^<]*<\/h/)+11, RLENGTH-14) + href = substr($0, match($0, '$href_regex')+6, RLENGTH-7) + '"$common_awk_script"' + }' + else + grepcmd="grep -Eo" + echoargs="-e" + awkscript='{ + level = substr($0, 3, 1) + text = substr($0, match($0, /">.*<\/h/)+2, RLENGTH-5) + href = substr($0, match($0, '$href_regex')+6, RLENGTH-7) + '"$common_awk_script"' + }' + fi + + # if closed is on the new line, then move it on the prev line + # for example: + # was: The command foo1 + # + # became: The command foo1 + sed -e ':a' -e 'N' -e '$!ba' -e 's/\n<\/h/<\/h/g' | + + # Sometimes a line can start with . Fix that. + sed -e ':a' -e 'N' -e '$!ba' -e 's/\n//g' | sed 's/<\/code>//g' | + + # remove g-emoji + sed 's/]*[^<]*<\/g-emoji> //g' | + + # now all rows are like: + #

title

.. + # format result line + # * $0 - whole string + # * last element of each row: "/dev/null; then + $tool --version | head -n 1 + else + echo "not installed" + fi + done +} + +show_help() { + local app_name + app_name=$(basename "$0") + echo "GitHub TOC generator ($app_name): $gh_toc_version" + echo "" + echo "Usage:" + echo " $app_name [options] src [src] Create TOC for a README file (url or local path)" + echo " $app_name - Create TOC for markdown from STDIN" + echo " $app_name --help Show help" + echo " $app_name --version Show version" + echo "" + echo "Options:" + echo " --indent Set indent size. Default: 3." + echo " --insert Insert new TOC into original file. For local files only. Default: false." + echo " See https://github.com/ekalinin/github-markdown-toc/issues/41 for details." + echo " --no-backup Remove backup file. Set --insert as well. Default: false." + echo " --hide-footer Do not write date & author of the last TOC update. Set --insert as well. Default: false." + echo " --skip-header Hide entry of the topmost headlines. Default: false." + echo " See https://github.com/ekalinin/github-markdown-toc/issues/125 for details." + echo "" +} + +# +# Options handlers +# +gh_toc_app() { + local need_replace="no" + local indent=3 + + if [ "$1" = '--help' ] || [ $# -eq 0 ] ; then + show_help + return + fi + + if [ "$1" = '--version' ]; then + show_version + return + fi + + if [ "$1" = '--indent' ]; then + indent="$2" + shift 2 + fi + + if [ "$1" = "-" ]; then + if [ -z "$TMPDIR" ]; then + TMPDIR="/tmp" + elif [ -n "$TMPDIR" ] && [ ! -d "$TMPDIR" ]; then + mkdir -p "$TMPDIR" + fi + local gh_tmp_md + if [ "`uname -s`" == "OS/390" ]; then + local timestamp + timestamp=$(date +%m%d%Y%H%M%S) + gh_tmp_md="$TMPDIR/tmp.$timestamp" + else + gh_tmp_md=$(mktemp "$TMPDIR/tmp.XXXXXX") + fi + while read -r input; do + echo "$input" >> "$gh_tmp_md" + done + gh_toc_md2html "$gh_tmp_md" | gh_toc_grab "" "$indent" + return + fi + + if [ "$1" = '--insert' ]; then + need_replace="yes" + shift + fi + + if [ "$1" = '--no-backup' ]; then + need_replace="yes" + no_backup="yes" + shift + fi + + if [ "$1" = '--hide-footer' ]; then + need_replace="yes" + no_footer="yes" + shift + fi + + if [ "$1" = '--skip-header' ]; then + skip_header="yes" + shift + fi + + + for md in "$@" + do + echo "" + gh_toc "$md" "$#" "$need_replace" "$no_backup" "$no_footer" "$indent" "$skip_header" + done + + echo "" + echo "" +} + +# +# Entry point +# +gh_toc_app "$@" \ No newline at end of file diff --git a/Scripts/lint.sh b/Scripts/lint.sh new file mode 100755 index 0000000..788d5ab --- /dev/null +++ b/Scripts/lint.sh @@ -0,0 +1,47 @@ +#!/bin/sh + +if [ -z "$SRCROOT" ]; then + SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + PACKAGE_DIR="${SCRIPT_DIR}/.." +else + PACKAGE_DIR="${SRCROOT}" +fi + +if [ -z "$GITHUB_ACTION" ]; then + MINT_CMD="/opt/homebrew/bin/mint" +else + MINT_CMD="mint" +fi + +export MINT_PATH="$PACKAGE_DIR/.mint" +MINT_ARGS="-n -m $PACKAGE_DIR/Mintfile --silent" +MINT_RUN="$MINT_CMD run $MINT_ARGS" + +pushd $PACKAGE_DIR + +$MINT_CMD bootstrap -m Mintfile + +if [ "$LINT_MODE" == "NONE" ]; then + exit +elif [ "$LINT_MODE" == "STRICT" ]; then + SWIFTFORMAT_OPTIONS="" + SWIFTLINT_OPTIONS="--strict" +else + SWIFTFORMAT_OPTIONS="" + SWIFTLINT_OPTIONS="" +fi + +pushd $PACKAGE_DIR + +$MINT_RUN swift-openapi-generator generate --output-directory Sources/AviaryInsights/Generated --config openapi-generator-config.yaml openapi.yaml + +if [ -z "$CI" ]; then + $MINT_RUN swiftformat . + $MINT_RUN swiftlint --fix +fi + +$MINT_RUN periphery scan +$MINT_RUN swiftformat --lint $SWIFTFORMAT_OPTIONS . +$MINT_RUN swiftlint lint $SWIFTLINT_OPTIONS + +popd diff --git a/Sources/AviaryInsights/Documentation.docc/AviaryInsights.md b/Sources/AviaryInsights/Documentation.docc/AviaryInsights.md new file mode 100644 index 0000000..78adb29 --- /dev/null +++ b/Sources/AviaryInsights/Documentation.docc/AviaryInsights.md @@ -0,0 +1,127 @@ +# ``AviaryInsights`` + +Easy to use Swift Package for recording pageviews and custom events for [Plausible](https://plausible.io). + +## Overview + +[Plausible](https://plausible.io) provides simple and meaningful insights into your website's' or app's traffic without invading the privacy of your visitors. However, integrating Plausible into a Swift application can be complex and time-consuming. AviaryInsights simplifies this process, allowing you to focus on building your application while still gaining the valuable insights that Plausible provides. + +``AviaryInsights`` provides a full set of features to work with [Plausible's API](https://plausible.io/docs/events-api): + +- **Event tracking** Define and track custom events in your application. +- **Revenue tracking** Track revenue data associated with events. +- **Plausible API integration** Send your events to the [Plausible API](https://plausible.io/docs/events-api) for further analysis. + +### Requirements + +**Apple Platforms** + +- Xcode 15 or later +- Swift 5.9 or later +- iOS 13 / watchOS 6 / tvOS 13 / visionOS 1 / macCatalyst 13 / macOS 10.15 or later deployment targets + +**Linux** + +- Ubuntu 20.04 or later +- Swift 5.9 or later + +### Installation + +To add the AviaryInsights package to your Xcode project, select File > Swift Packages > Add Package Dependency and enter the repository URL. + +Using Swift Package Manager add the repository url: + +``` +https://github.com/brightdigit/AviartyInsights.git +``` + +### Usage + +Here's a basic example for setting up the ``Plausible`` client add sending an ``Event``. + +```swift +import AviaryInsights + +// Initialize the client with your bundle identifier as the domain +let plausible = Plausible(domain: "com.example.yourApp") + +// Define an event +let event = Event(url: "app://localhost/login") + +// Send the event +plausible.send(event: event) +``` + +#### Plausible Client + +``Plausible`` is a client for interacting with the [Plausible API](https://plausible.io/docs/events-api). It is initialized with a domain, which is typically your app's bundle identifier. The ``Plausible`` client is used to send events to the [Plausible API](https://plausible.io/docs/events-api) for tracking and analysis. + +To construct a ``Plausible`` instance, you need to provide a domain. The domain is a string that identifies your application, typically the bundle identifier of your app. + +```swift +let plausible = Plausible(domain: "com.example.yourApp") +``` + +By default ``Plausible`` uses a [`URLSessionTransport`](https://github.com/apple/swift-openapi-urlsession), however you can use alternatives such as [`AsyncHTTPClient`](https://github.com/swift-server/swift-openapi-async-http-client). + +#### Sending an `Event` + +``Event`` represents an event in your system. An event has a name and URL, and optionally, a domain, referrer, custom properties (`props`), and revenue information. You can create an ``Event`` instance and send it using the ``Plausible`` client. + +To construct an ``Event``, you need to provide at least a name. The name is a string that identifies the event you want to track. Optionally, you can also provide: + +- **`name`** string that represents the name of the event. _Default_ is **pageview**. +- **`url`** string that represents the URL where the event occurred. For an app you may wish to use a app url such as `app://localhost/login`. +- `domain` _optional_ string that identifies the domain in which the event occurred. Overrides whatever was set in the ``Plausible`` instance. +- `referrer` _optional_ string that represents the URL of the referrer +- `props` _optional_ dictionary of custom properties associated with the event. +- `revenue` _optional_ `Revenue` instance that represents the revenue data associated with the event + +```swift +let event = Event + name: "eventName", + domain: "domain", + url: "url", + referrer: "referrer", + props: ["key": "value"], + revenue: Revenue( + currencyCode: "USD", + amount: 100 + ) +) +``` + +AviaryInsights provides two ways to send events to the Plausible API: + +##### Asynchronous Throwing Method + +This method sends an event to the Plausible API and throws an error if the operation fails. This is useful when you want to handle errors in your own way. Here's an example: + +```swift +do { + try await plausible.postEvent(event) +} catch { + print("Failed to post event: \(error)") +} +``` + +##### Synchronous Method + +This method sends an event to the Plausible API in the background and ignores any errors that occur. This is useful when you don't need to handle errors and want to fire-and-forget the event. Here's an example: + +```swift +plausible.postEvent(event) +``` + +In both cases, `event` is an instance of ``Event`` that you want to send to the Plausible API. + +## Topics + +### Creating a Client + +- ``Plausible`` + +### Building an Event + +- ``Event`` +- ``Revenue`` diff --git a/Sources/AviaryInsights/Event.swift b/Sources/AviaryInsights/Event.swift new file mode 100644 index 0000000..6ac57db --- /dev/null +++ b/Sources/AviaryInsights/Event.swift @@ -0,0 +1,80 @@ +// +// Event.swift +// AviaryInsights +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Represents an event in Plausible, such as a pageview or a custom event. +/// +/// ``Event`` represents an event you can send to a ``Plausible``. +/// An event has a name and URL, and +/// optionally, a domain, referrer, custom properties (`props`), and revenue information. +public struct Event: Sendable { + /// Default name for a pageview event. + public static let pageview = "pageview" + + /// Name of the event. + public let name: String + + /// Domain name of the site in Plausible. + public let domain: String? + + /// URL of the page where the event was triggered. + public let url: String + + /// Referrer for this event. + public let referrer: String? + + /// Custom properties for the event. + public let props: [String: any Sendable]? + + /// Revenue data for this event. + public let revenue: Revenue? + + /// Initializes an event. + /// - Parameters: + /// - url: URL of the page where the event was triggered. + /// - name: Name of the event. Defaults to `pageview`. + /// - domain: Domain name of the site in Plausible. Defaults to `nil`. + /// - referrer: Referrer for this event. Defaults to `nil`. + /// - props: Custom properties for the event. Defaults to `nil`. + /// - revenue: Revenue data for this event. Defaults to `nil`. + public init( + url: String, + name: String = Self.pageview, + domain: String? = nil, + referrer: String? = nil, + props: [String: (any Sendable)?]? = nil, + revenue: Revenue? = nil + ) { + self.name = name + self.domain = domain + self.url = url + self.referrer = referrer + self.props = props + self.revenue = revenue + } +} diff --git a/Sources/AviaryInsights/Generated/Client.swift b/Sources/AviaryInsights/Generated/Client.swift new file mode 100644 index 0000000..ff009e8 --- /dev/null +++ b/Sources/AviaryInsights/Generated/Client.swift @@ -0,0 +1,110 @@ +// Generated by swift-openapi-generator, do not modify. +@_spi(Generated) import OpenAPIRuntime +#if os(Linux) +@preconcurrency import struct Foundation.URL +@preconcurrency import struct Foundation.Data +@preconcurrency import struct Foundation.Date +#else +import struct Foundation.URL +import struct Foundation.Data +import struct Foundation.Date +#endif +import HTTPTypes +/// Plausible API allows you to record pageviews or custom events. +internal struct Client: APIProtocol { + /// The underlying HTTP client. + private let client: UniversalClient + /// Creates a new client. + /// - Parameters: + /// - serverURL: The server URL that the client connects to. Any server + /// URLs defined in the OpenAPI document are available as static methods + /// on the ``Servers`` type. + /// - configuration: A set of configuration values for the client. + /// - transport: A transport that performs HTTP operations. + /// - middlewares: A list of middlewares to call before the transport. + internal init( + serverURL: Foundation.URL, + configuration: Configuration = .init(), + transport: any ClientTransport, + middlewares: [any ClientMiddleware] = [] + ) { + self.client = .init( + serverURL: serverURL, + configuration: configuration, + transport: transport, + middlewares: middlewares + ) + } + private var converter: Converter { + client.converter + } + /// Record a pageview or custom event + /// + /// - Remark: HTTP `POST /event`. + /// - Remark: Generated from `#/paths//event/post`. + internal func post_sol_event(_ input: Operations.post_sol_event.Input) async throws -> Operations.post_sol_event.Output { + try await client.send( + input: input, + forOperation: Operations.post_sol_event.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/event", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 202: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.post_sol_event.Output.Accepted.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + OpenAPIRuntime.OpenAPIObjectContainer.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .accepted(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } +} diff --git a/Sources/AviaryInsights/Generated/Types.swift b/Sources/AviaryInsights/Generated/Types.swift new file mode 100644 index 0000000..1d15ae4 --- /dev/null +++ b/Sources/AviaryInsights/Generated/Types.swift @@ -0,0 +1,274 @@ +// Generated by swift-openapi-generator, do not modify. +@_spi(Generated) import OpenAPIRuntime +#if os(Linux) +@preconcurrency import struct Foundation.URL +@preconcurrency import struct Foundation.Data +@preconcurrency import struct Foundation.Date +#else +import struct Foundation.URL +import struct Foundation.Data +import struct Foundation.Date +#endif +/// A type that performs HTTP operations defined by the OpenAPI document. +internal protocol APIProtocol: Sendable { + /// Record a pageview or custom event + /// + /// - Remark: HTTP `POST /event`. + /// - Remark: Generated from `#/paths//event/post`. + func post_sol_event(_ input: Operations.post_sol_event.Input) async throws -> Operations.post_sol_event.Output +} + +/// Convenience overloads for operation inputs. +extension APIProtocol { + /// Record a pageview or custom event + /// + /// - Remark: HTTP `POST /event`. + /// - Remark: Generated from `#/paths//event/post`. + internal func post_sol_event( + headers: Operations.post_sol_event.Input.Headers = .init(), + body: Operations.post_sol_event.Input.Body + ) async throws -> Operations.post_sol_event.Output { + try await post_sol_event(Operations.post_sol_event.Input( + headers: headers, + body: body + )) + } +} + +/// Server URLs defined in the OpenAPI document. +internal enum Servers { + /// Production server + internal static func server1() throws -> Foundation.URL { + try Foundation.URL( + validatingOpenAPIServerURL: "https://plausible.io/api", + variables: [] + ) + } +} + +/// Types generated from the components section of the OpenAPI document. +internal enum Components { + /// Types generated from the `#/components/schemas` section of the OpenAPI document. + internal enum Schemas {} + /// Types generated from the `#/components/parameters` section of the OpenAPI document. + internal enum Parameters {} + /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. + internal enum RequestBodies {} + /// Types generated from the `#/components/responses` section of the OpenAPI document. + internal enum Responses {} + /// Types generated from the `#/components/headers` section of the OpenAPI document. + internal enum Headers {} +} + +/// API operations, with input and output types, generated from `#/paths` in the OpenAPI document. +internal enum Operations { + /// Record a pageview or custom event + /// + /// - Remark: HTTP `POST /event`. + /// - Remark: Generated from `#/paths//event/post`. + internal enum post_sol_event { + internal static let id: Swift.String = "post/event" + internal struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/event/POST/header`. + internal struct Headers: Sendable, Hashable { + internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + internal var headers: Operations.post_sol_event.Input.Headers + /// - Remark: Generated from `#/paths/event/POST/requestBody`. + @frozen internal enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/event/POST/requestBody/json`. + internal struct jsonPayload: Codable, Hashable, Sendable { + /// Name of the event. Can be 'pageview' or a custom event name. + /// + /// - Remark: Generated from `#/paths/event/POST/requestBody/json/name`. + internal var name: Swift.String + /// Domain name of the site in Plausible. + /// + /// - Remark: Generated from `#/paths/event/POST/requestBody/json/domain`. + internal var domain: Swift.String + /// URL of the page where the event was triggered. + /// + /// - Remark: Generated from `#/paths/event/POST/requestBody/json/url`. + internal var url: Swift.String + /// Referrer for this event. + /// + /// - Remark: Generated from `#/paths/event/POST/requestBody/json/referrer`. + internal var referrer: Swift.String? + /// Custom properties for the event. + /// + /// - Remark: Generated from `#/paths/event/POST/requestBody/json/props`. + internal var props: OpenAPIRuntime.OpenAPIObjectContainer? + /// Revenue data for this event. + /// + /// - Remark: Generated from `#/paths/event/POST/requestBody/json/revenue`. + internal struct revenuePayload: Codable, Hashable, Sendable { + /// ISO 4217 string representing the currency code. + /// + /// - Remark: Generated from `#/paths/event/POST/requestBody/json/revenue/currency`. + internal var currency: Swift.String + /// Revenue amount. + /// + /// - Remark: Generated from `#/paths/event/POST/requestBody/json/revenue/amount`. + internal var amount: Swift.Double + /// Creates a new `revenuePayload`. + /// + /// - Parameters: + /// - currency: ISO 4217 string representing the currency code. + /// - amount: Revenue amount. + internal init( + currency: Swift.String, + amount: Swift.Double + ) { + self.currency = currency + self.amount = amount + } + internal enum CodingKeys: String, CodingKey { + case currency + case amount + } + } + /// Revenue data for this event. + /// + /// - Remark: Generated from `#/paths/event/POST/requestBody/json/revenue`. + internal var revenue: Operations.post_sol_event.Input.Body.jsonPayload.revenuePayload? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - name: Name of the event. Can be 'pageview' or a custom event name. + /// - domain: Domain name of the site in Plausible. + /// - url: URL of the page where the event was triggered. + /// - referrer: Referrer for this event. + /// - props: Custom properties for the event. + /// - revenue: Revenue data for this event. + internal init( + name: Swift.String, + domain: Swift.String, + url: Swift.String, + referrer: Swift.String? = nil, + props: OpenAPIRuntime.OpenAPIObjectContainer? = nil, + revenue: Operations.post_sol_event.Input.Body.jsonPayload.revenuePayload? = nil + ) { + self.name = name + self.domain = domain + self.url = url + self.referrer = referrer + self.props = props + self.revenue = revenue + } + internal enum CodingKeys: String, CodingKey { + case name + case domain + case url + case referrer + case props + case revenue + } + } + /// - Remark: Generated from `#/paths/event/POST/requestBody/content/application\/json`. + case json(Operations.post_sol_event.Input.Body.jsonPayload) + } + internal var body: Operations.post_sol_event.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + /// - body: + internal init( + headers: Operations.post_sol_event.Input.Headers = .init(), + body: Operations.post_sol_event.Input.Body + ) { + self.headers = headers + self.body = body + } + } + @frozen internal enum Output: Sendable, Hashable { + internal struct Accepted: Sendable, Hashable { + /// - Remark: Generated from `#/paths/event/POST/responses/202/content`. + @frozen internal enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/event/POST/responses/202/content/application\/json`. + case json(OpenAPIRuntime.OpenAPIObjectContainer) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + internal var json: OpenAPIRuntime.OpenAPIObjectContainer { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + internal var body: Operations.post_sol_event.Output.Accepted.Body + /// Creates a new `Accepted`. + /// + /// - Parameters: + /// - body: Received HTTP response body + internal init(body: Operations.post_sol_event.Output.Accepted.Body) { + self.body = body + } + } + /// Accepted + /// + /// - Remark: Generated from `#/paths//event/post/responses/202`. + /// + /// HTTP response code: `202 accepted`. + case accepted(Operations.post_sol_event.Output.Accepted) + /// The associated value of the enum case if `self` is `.accepted`. + /// + /// - Throws: An error if `self` is not `.accepted`. + /// - SeeAlso: `.accepted`. + internal var accepted: Operations.post_sol_event.Output.Accepted { + get throws { + switch self { + case let .accepted(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "accepted", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen internal enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + internal init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + internal var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + internal static var allCases: [Self] { + [ + .json + ] + } + } + } +} diff --git a/Sources/AviaryInsights/Operations.swift b/Sources/AviaryInsights/Operations.swift new file mode 100644 index 0000000..2e65d0a --- /dev/null +++ b/Sources/AviaryInsights/Operations.swift @@ -0,0 +1,55 @@ +// +// Operations.swift +// AviaryInsights +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import OpenAPIRuntime + +extension Operations.post_sol_event.Input.Body { + internal init(event: Event, defaultDomain: String) { + self = .json(.init(event: event, defaultDomain: defaultDomain)) + } +} + +extension Operations.post_sol_event.Input.Body.jsonPayload { + internal init(event: Event, defaultDomain: String) { + let propsContainer: OpenAPIObjectContainer? + do { + propsContainer = try event.props.flatMap(OpenAPIObjectContainer.init) + } catch { + assertionFailure(error.localizedDescription) + propsContainer = nil + } + self.init( + name: event.name, + domain: event.domain ?? defaultDomain, + url: event.url, + referrer: event.referrer, + props: propsContainer + ) + } +} diff --git a/Sources/AviaryInsights/Plausible.swift b/Sources/AviaryInsights/Plausible.swift new file mode 100644 index 0000000..1bb4c50 --- /dev/null +++ b/Sources/AviaryInsights/Plausible.swift @@ -0,0 +1,170 @@ +// +// Plausible.swift +// AviaryInsights +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import OpenAPIRuntime +import OpenAPIURLSession + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +// swiftlint:disable line_length + +/// Represents an interface to interact with the Plausible API. +/// +/// ``Plausible`` is a client for interacting with the Plausible API. +/// It is initialized with a domain, which is typically your app's bundle identifier. +/// The ``Plausible`` client is used to send events to the Plausible API for tracking and analysis. +/// +/// To construct a ``Plausible`` instance, you need to provide a domain. +/// The domain is a string that identifies your application, typically the bundle identifier of your app. +/// +/// ```swift +/// let plausible = Plausible(domain: "com.example.yourApp") +/// ``` +/// By default ``Plausible`` uses a [`URLSessionTransport`](https://github.com/apple/swift-openapi-urlsession), +/// however you can use alternatives such as [`AsyncHTTPClient`](https://github.com/swift-server/swift-openapi-async-http-client). +/// +/// ## Sending Event +/// AviaryInsights provides two ways to send an ``Event`` to the Plausible API: +/// ### Asynchronous Throwing Method +/// +/// This method sends an event to the Plausible API and throws an error if the operation fails. +/// This is useful when you want to handle errors in your own way. Here's an example: +/// +/// ```swift +/// do { +/// try await plausible.postEvent(event) +/// } catch { +/// print("Failed to post event: \(error)") +/// } +/// ``` +/// +/// ### Synchronous Method +/// +/// This method sends an event to the Plausible API in the background and ignores any errors that occur. +/// This is useful when you don't need to handle errors and +/// want to fire-and-forget the event. Here's an example: +/// +/// ```swift +/// plausible.postEvent(event) +/// ``` +/// In both cases, `event` is an instance of ``Event`` that you want to send to the Plausible API. +public struct Plausible: Sendable { + // swiftlint:enable line_length + // swiftlint:disable force_try + /// Default server URL for the Plausible API. + public static let defaultServerURL = try! Servers.server1() + // swiftlint:enable force_try + + private let client: Client + /// Default domain associated with the Plausible instance. + public let defaultDomain: String + + private init(client: Client, defaultDomain: String) { + self.client = client + self.defaultDomain = defaultDomain + } + + /// Initializes a Plausible instance with a custom `ClientTransport`. + /// - Parameters: + /// - transport: Client transport for sending requests. + /// - defaultDomain: Default domain associated with the Plausible instance. + /// - serverURL: Server URL for the Plausible API. Defaults to `defaultServerURL`. + public init( + transport: any ClientTransport, + defaultDomain: String, + serverURL: URL = Self.defaultServerURL + ) { + let client = Client(serverURL: serverURL, transport: transport) + self.init(client: client, defaultDomain: defaultDomain) + } + + /// Initializes a Plausible instance with a custom `URLSessionTransport.Configuration`. + /// - Parameters: + /// - defaultDomain: Default domain associated with the Plausible instance. + /// - serverURL: Server URL for the Plausible API. Defaults to `defaultServerURL`. + /// - configuration: Configuration for URLSessionTransport. Defaults to `nil`. + public init( + defaultDomain: String, + serverURL: URL = Self.defaultServerURL, + configuration: URLSessionTransport.Configuration? = nil + ) { + let transport: URLSessionTransport = if let configuration { + .init(configuration: configuration) + } else { + .init() + } + self.init( + transport: transport, + defaultDomain: defaultDomain, + serverURL: serverURL + ) + } + + /// Initializes a Plausible instance with a custom URLSession. + /// - Parameters: + /// - session: URLSession to use for making requests. + /// - defaultDomain: Default domain associated with the Plausible instance. + /// - serverURL: Server URL for the Plausible API. Defaults to `defaultServerURL`. + public init( + session: URLSession, + defaultDomain: String, + serverURL: URL = Self.defaultServerURL + ) { + self.init( + defaultDomain: defaultDomain, + serverURL: serverURL, + configuration: .init(session: session) + ) + } + + /// Sends an event to the Plausible API. + /// - Parameter event: Event to be sent. + public func postEvent(_ event: Event) async throws { + _ = try await client.post_sol_event( + body: .init(event: event, defaultDomain: defaultDomain) + ).accepted + } +} + +extension Plausible { + /// Sends the event to Plausible in the background. + /// - Parameter event: An analytic event to record. + public func postEvent(_ event: Event) { + Task { + do { + try await postEvent(event) + } catch { + print(error.localizedDescription) + } + } + } +} diff --git a/Sources/AviaryInsights/Revenue.swift b/Sources/AviaryInsights/Revenue.swift new file mode 100644 index 0000000..21edde1 --- /dev/null +++ b/Sources/AviaryInsights/Revenue.swift @@ -0,0 +1,46 @@ +// +// Revenue.swift +// AviaryInsights +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Represents revenue data for an event in Plausible. +public struct Revenue: Sendable { + /// Currency code for the revenue amount. + public let currency: String + + /// Amount of revenue. + public let amount: Double + + /// Initializes revenue data for an event. + /// - Parameters: + /// - currency: Currency code for the revenue amount. + /// - amount: Amount of revenue. + public init(currency: String, amount: Double) { + self.currency = currency + self.amount = amount + } +} diff --git a/Tests/AviaryInsightsTests/AviaryInsightsTests.swift b/Tests/AviaryInsightsTests/AviaryInsightsTests.swift new file mode 100644 index 0000000..5a67841 --- /dev/null +++ b/Tests/AviaryInsightsTests/AviaryInsightsTests.swift @@ -0,0 +1,82 @@ +// +// AviaryInsightsTests.swift +// AviaryInsights +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@testable import AviaryInsights +import Foundation +import XCTest + +internal final class AviaryInsightsTests: XCTestCase { + private let decoder = JSONDecoder() + + fileprivate func assert( + events: [Event], + requests: [MockTransport.Request], + defaultDomain: String + ) async throws { + for (event, request) in zip(events, requests) { + guard let body = request.body else { + XCTAssertNotNil(request.body) + continue + } + let data = try await Data(collecting: body, upTo: .max) + let actualJSONPayload = try decoder.decode( + Operations.post_sol_event.Input.Body.jsonPayload.self, + from: data + ) + let expectedJSONPayload = Operations.post_sol_event.Input.Body.jsonPayload( + event: event, + defaultDomain: defaultDomain + ) + XCTAssertEqual(actualJSONPayload, expectedJSONPayload) + } + } + + internal func testPostEvent() async throws { + let transport = MockTransport { + .init(response: .init(status: .accepted), body: "{}") + } + + let defaultDomain = UUID().uuidString + let client = Plausible(transport: transport, defaultDomain: defaultDomain) + let events: [Event] = { + let count: Int = .random(in: 10 ... 20) + return (0 ..< count).map { _ in + Event.random() + } + }() + + for event in events { + try await client.postEvent(event) + } + + let requests = await transport.sentRequests + + try await assert(events: events, requests: requests, defaultDomain: defaultDomain) + } +} diff --git a/Tests/AviaryInsightsTests/Event.swift b/Tests/AviaryInsightsTests/Event.swift new file mode 100644 index 0000000..c2b7750 --- /dev/null +++ b/Tests/AviaryInsightsTests/Event.swift @@ -0,0 +1,61 @@ +// +// Event.swift +// AviaryInsights +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import AviaryInsights +import Foundation + +extension Event { + private static func randomProps() -> [String: (any Sendable)?] { + var values = [String: (any Sendable)?]() + let keyCount: Int = .random(in: 3 ... 7) + for _ in 0 ..< keyCount { + let value: any Sendable + let type: Bool = .random() + switch type { + case false: + value = Int.random(in: 100 ... 999) + case true: + value = UUID().uuidString + } + values[UUID().uuidString] = value + } + return values + } + + internal static func random() -> Event { + Event( + url: UUID().uuidString, + name: UUID().uuidString, + domain: Bool.random() ? UUID().uuidString : nil, + referrer: Bool.random() ? UUID().uuidString : nil, + props: Bool.random() ? randomProps() : nil, + revenue: Bool.random() ? .random() : nil + ) + } +} diff --git a/Tests/AviaryInsightsTests/MockTransport.swift b/Tests/AviaryInsightsTests/MockTransport.swift new file mode 100644 index 0000000..f0b37c6 --- /dev/null +++ b/Tests/AviaryInsightsTests/MockTransport.swift @@ -0,0 +1,81 @@ +// +// MockTransport.swift +// AviaryInsights +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import HTTPTypes +import OpenAPIRuntime + +internal final actor MockTransport: ClientTransport { + // periphery:ignore + internal struct Request { + internal init(request: HTTPRequest, body: HTTPBody? = nil, baseURL: URL, operationID: String) { + self.request = request + self.body = body + self.baseURL = baseURL + self.operationID = operationID + } + + private let request: HTTPRequest + internal let body: HTTPBody? + private let baseURL: URL + private let operationID: String + } + + internal struct Response { + private let response: HTTPResponse + private let body: HTTPBody? + + internal init(response: HTTPResponse, body: HTTPBody? = nil) { + self.response = response + self.body = body + } + + fileprivate func tuple() -> (HTTPResponse, HTTPBody?) { + (response, body) + } + } + + internal private(set) var sentRequests = [Request]() + private let nextResponse: @Sendable () -> Response + + internal init(nextResponse: @escaping @Sendable () -> Response) { + sentRequests = [] + self.nextResponse = nextResponse + } + + internal func send( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> (HTTPResponse, HTTPBody?) { + sentRequests.append(.init(request: request, body: body, baseURL: baseURL, operationID: operationID)) + return nextResponse().tuple() + } +} diff --git a/Tests/AviaryInsightsTests/Revenue.swift b/Tests/AviaryInsightsTests/Revenue.swift new file mode 100644 index 0000000..45e29fb --- /dev/null +++ b/Tests/AviaryInsightsTests/Revenue.swift @@ -0,0 +1,37 @@ +// +// Revenue.swift +// AviaryInsights +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import AviaryInsights +import Foundation + +extension Revenue { + internal static func random() -> Revenue { + .init(currency: UUID().uuidString, amount: .random(in: 20 ... 999)) + } +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..951b97b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "Tests" diff --git a/openapi-generator-config.yaml b/openapi-generator-config.yaml new file mode 100644 index 0000000..1df6f28 --- /dev/null +++ b/openapi-generator-config.yaml @@ -0,0 +1,4 @@ +generate: + - types + - client +accessModifier: internal diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..9aca951 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,67 @@ +openapi: 3.0.0 +info: + title: Plausible API + description: Plausible API allows you to record pageviews or custom events. + version: 1.0.0 +servers: + - url: https://plausible.io/api + description: Production server +paths: + /event: + post: + summary: Record a pageview or custom event + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - domain + - url + properties: + name: + type: string + description: Name of the event. Can be 'pageview' or a custom event name. + example: pageview + domain: + type: string + description: Domain name of the site in Plausible. + example: dummy.site + url: + type: string + format: uri + description: URL of the page where the event was triggered. + example: http://dummy.site + referrer: + type: string + description: Referrer for this event. + example: http://referrer.site + props: + type: object + description: Custom properties for the event. + example: {"author": "John Doe", "logged_in": "false"} + revenue: + type: object + description: Revenue data for this event. + required: + - currency + - amount + properties: + currency: + type: string + description: ISO 4217 string representing the currency code. + example: USD + amount: + type: number + description: Revenue amount. + example: 1322.22 + responses: + '202': + description: Accepted + content: + application/json: + schema: + type: object + example: {} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..a5d5350 --- /dev/null +++ b/project.yml @@ -0,0 +1,13 @@ +name: AviaryInsights +settings: + LINT_MODE: ${LINT_MODE} +packages: + StealthyStash: + path: . +aggregateTargets: + Lint: + buildScripts: + - path: Scripts/lint.sh + name: Lint + basedOnDependencyAnalysis: false + schemes: {} \ No newline at end of file