├── .github └── workflows │ ├── ci.yml │ ├── release-hook-on-closed.yml │ ├── release-hook-on-push.yml │ ├── release-perform.yml │ ├── release-request.yml │ └── release-retry.yml ├── .gitignore ├── .rubocop.yml ├── .toys ├── .data │ └── releases.yml ├── .toys.rb ├── ci.rb └── cucumber.rb ├── .yardopts ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── MAINTAINERS.md ├── README.md ├── RELEASING.md ├── cloud_events.gemspec ├── examples ├── client │ ├── Gemfile │ └── send.rb └── server │ ├── Gemfile │ └── app.rb ├── features ├── step_definitions │ └── steps.rb └── support │ └── env.rb ├── lib ├── cloud_events.rb └── cloud_events │ ├── content_type.rb │ ├── errors.rb │ ├── event.rb │ ├── event │ ├── field_interpreter.rb │ ├── opaque.rb │ ├── utils.rb │ ├── v0.rb │ └── v1.rb │ ├── format.rb │ ├── http_binding.rb │ ├── json_format.rb │ ├── text_format.rb │ └── version.rb └── test ├── event ├── test_opaque.rb ├── test_v0.rb └── test_v1.rb ├── helper.rb ├── test_content_type.rb ├── test_event.rb ├── test_examples.rb ├── test_http_binding.rb ├── test_json_format.rb └── test_text_format.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI tests" 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | tests: 14 | if: ${{ github.repository == 'cloudevents/sdk-ruby' }} 15 | strategy: 16 | matrix: 17 | include: 18 | - os: ubuntu-latest 19 | ruby: "2.7" 20 | flags: "--test --cucumber" 21 | - os: ubuntu-latest 22 | ruby: "3.0" 23 | flags: "--test --cucumber" 24 | - os: ubuntu-latest 25 | ruby: "3.1" 26 | flags: "--test --cucumber" 27 | - os: ubuntu-latest 28 | ruby: "3.2" 29 | flags: "--test --cucumber" 30 | - os: ubuntu-latest 31 | ruby: "3.3" 32 | flags: "--test --cucumber" 33 | - os: ubuntu-latest 34 | ruby: "3.4" 35 | flags: "--test --cucumber" 36 | - os: ubuntu-latest 37 | ruby: "3.4" 38 | flags: "--rubocop --yard --build" 39 | - os: ubuntu-latest 40 | ruby: jruby 41 | flags: "--test --cucumber" 42 | # TEMPORARY: Disable truffleruby due to some issues with bundler 43 | # - os: ubuntu-latest 44 | # ruby: truffleruby 45 | # flags: "--test --cucumber" 46 | - os: macos-latest 47 | ruby: "3.4" 48 | flags: "--test --cucumber" 49 | - os: windows-latest 50 | ruby: "3.4" 51 | flags: "--test --cucumber" 52 | fail-fast: false 53 | runs-on: ${{ matrix.os }} 54 | steps: 55 | - name: Install Ruby ${{ matrix.ruby }} 56 | uses: ruby/setup-ruby@v1 57 | with: 58 | ruby-version: ${{ matrix.ruby }} 59 | - name: Checkout repo 60 | uses: actions/checkout@v5 61 | - name: Install dependencies 62 | shell: bash 63 | run: "bundle install && gem install --no-document toys" 64 | - name: Run ${{ matrix.flags }} 65 | shell: bash 66 | run: | 67 | toys ci --only ${{ matrix.flags }} < /dev/null 68 | -------------------------------------------------------------------------------- /.github/workflows/release-hook-on-closed.yml: -------------------------------------------------------------------------------- 1 | name: "[release hook] Process release" 2 | 3 | on: 4 | pull_request: 5 | types: [closed] 6 | 7 | permissions: 8 | contents: write # required for creating releases 9 | pull-requests: write # required for updating label on PR, posting comments 10 | issues: write # required for creating new issues on failed releases 11 | 12 | jobs: 13 | release-process-request: 14 | if: ${{ github.repository == 'cloudevents/sdk-ruby' }} 15 | env: 16 | ruby_version: "3.4" 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Install Ruby ${{ env.ruby_version }} 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ env.ruby_version }} 23 | - name: Checkout repo 24 | uses: actions/checkout@v5 25 | - name: Install Toys 26 | run: "gem install --no-document toys" 27 | - name: Process release request 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} 31 | run: | 32 | toys release _onclosed --verbose \ 33 | "--event-path=${{ github.event_path }}" \ 34 | < /dev/null 35 | -------------------------------------------------------------------------------- /.github/workflows/release-hook-on-push.yml: -------------------------------------------------------------------------------- 1 | name: "[release hook] Update open releases" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write # required for pushing changes 10 | pull-requests: write # required for updating open release PRs 11 | 12 | jobs: 13 | release-update-open-requests: 14 | if: ${{ github.repository == 'cloudevents/sdk-ruby' }} 15 | env: 16 | ruby_version: "3.4" 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Install Ruby ${{ env.ruby_version }} 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ env.ruby_version }} 23 | - name: Checkout repo 24 | uses: actions/checkout@v5 25 | - name: Install Toys 26 | run: "gem install --no-document toys" 27 | - name: Update open releases 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | run: | 31 | toys release _onpush --verbose \ 32 | < /dev/null 33 | -------------------------------------------------------------------------------- /.github/workflows/release-perform.yml: -------------------------------------------------------------------------------- 1 | name: Force release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | name: 7 | description: Component to release 8 | required: true 9 | version: 10 | description: Version to release 11 | required: true 12 | flags: 13 | description: Extra flags to pass to the release script 14 | required: false 15 | default: "" 16 | 17 | permissions: 18 | contents: write # required for creating releases 19 | pull-requests: write # required for updating label on PR, posting comments 20 | issues: write # required for creating new issues on failed releases 21 | 22 | jobs: 23 | release-perform: 24 | if: ${{ github.repository == 'cloudevents/sdk-ruby' }} 25 | env: 26 | ruby_version: "3.4" 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Install Ruby ${{ env.ruby_version }} 30 | uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ${{ env.ruby_version }} 33 | - name: Checkout repo 34 | uses: actions/checkout@v5 35 | - name: Install Toys 36 | run: "gem install --no-document toys" 37 | - name: Perform release 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} 41 | run: | 42 | toys release perform --yes --verbose \ 43 | "--release-ref=${{ github.sha }}" \ 44 | ${{ github.event.inputs.flags }} \ 45 | "${{ github.event.inputs.name }}" "${{ github.event.inputs.version }}" \ 46 | < /dev/null 47 | -------------------------------------------------------------------------------- /.github/workflows/release-request.yml: -------------------------------------------------------------------------------- 1 | name: Open release request 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | names: 7 | description: Components to release (leave blank to release all components) 8 | required: false 9 | default: "" 10 | 11 | permissions: 12 | contents: write # required for pushing changes 13 | pull-requests: write # required for creating release PRs 14 | 15 | jobs: 16 | release-request: 17 | if: ${{ github.repository == 'cloudevents/sdk-ruby' }} 18 | env: 19 | ruby_version: "3.4" 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Install Ruby ${{ env.ruby_version }} 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ env.ruby_version }} 26 | - name: Checkout repo 27 | uses: actions/checkout@v4 28 | - name: Install Toys 29 | run: "gem install --no-document toys" 30 | - name: Open release pull request 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.TOYS_RELEASE_REQUEST_TOKEN || secrets.GITHUB_TOKEN }} 33 | run: | 34 | toys release request --yes --verbose \ 35 | "--target-branch=${{ github.ref }}" \ 36 | ${{ github.event.inputs.names }} \ 37 | < /dev/null 38 | -------------------------------------------------------------------------------- /.github/workflows/release-retry.yml: -------------------------------------------------------------------------------- 1 | name: Retry release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | release_pr: 7 | description: Release PR number 8 | required: true 9 | flags: 10 | description: Extra flags to pass to the release script 11 | required: false 12 | default: "" 13 | 14 | permissions: 15 | contents: write # required for creating releases 16 | pull-requests: write # required for updating label on PR, posting comments 17 | issues: write # required for creating new issues on failed releases 18 | 19 | jobs: 20 | release-retry: 21 | if: ${{ github.repository == 'cloudevents/sdk-ruby' }} 22 | env: 23 | ruby_version: "3.4" 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Install Ruby ${{ env.ruby_version }} 27 | uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: ${{ env.ruby_version }} 30 | - name: Checkout repo 31 | uses: actions/checkout@v5 32 | - name: Install Toys 33 | run: "gem install --no-document toys" 34 | - name: Retry release 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} 38 | run: | 39 | toys release retry --yes --verbose \ 40 | ${{ github.event.inputs.flags }} \ 41 | "${{ github.event.inputs.release_pr }}" \ 42 | < /dev/null 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | .bundle/ 3 | .yardoc/ 4 | Gemfile.lock 5 | coverage/ 6 | doc/ 7 | pkg/ 8 | tmp/ 9 | features/conformance 10 | vendor/ 11 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: disable 3 | SuggestExtensions: false 4 | TargetRubyVersion: 2.7 5 | 6 | Gemspec/AddRuntimeDependency: 7 | Enabled: true 8 | Gemspec/AttributeAssignment: 9 | Enabled: true 10 | Gemspec/DeprecatedAttributeAssignment: 11 | Enabled: true 12 | Gemspec/DevelopmentDependencies: 13 | Enabled: true 14 | Gemspec/RequireMFA: 15 | Enabled: false 16 | 17 | Layout/EmptyLineAfterGuardClause: 18 | Enabled: false 19 | Layout/EmptyLinesAfterModuleInclusion: 20 | Enabled: true 21 | Layout/HashAlignment: 22 | Enabled: false 23 | Layout/LineContinuationLeadingSpace: 24 | Enabled: true 25 | Layout/LineContinuationSpacing: 26 | Enabled: true 27 | Layout/LineEndStringConcatenationIndentation: 28 | Enabled: true 29 | Layout/LineLength: 30 | Max: 120 31 | Layout/SpaceBeforeBrackets: 32 | Enabled: true 33 | Layout/SpaceInsideHashLiteralBraces: 34 | Enabled: false 35 | 36 | 37 | Lint/AmbiguousAssignment: 38 | Enabled: true 39 | Lint/AmbiguousOperatorPrecedence: 40 | Enabled: true 41 | Lint/AmbiguousRange: 42 | Enabled: true 43 | Lint/ArrayLiteralInRegexp: 44 | Enabled: true 45 | Lint/ConstantOverwrittenInRescue: 46 | Enabled: true 47 | Lint/ConstantReassignment: 48 | Enabled: true 49 | Lint/CopDirectiveSyntax: 50 | Enabled: true 51 | Lint/DeprecatedConstants: 52 | Enabled: true 53 | Lint/DuplicateBranch: 54 | Enabled: true 55 | Lint/DuplicateMagicComment: 56 | Enabled: true 57 | Lint/DuplicateMatchPattern: 58 | Enabled: true 59 | Lint/DuplicateRegexpCharacterClassElement: 60 | Enabled: true 61 | Lint/DuplicateSetElement: 62 | Enabled: true 63 | Lint/EmptyBlock: 64 | Enabled: true 65 | Lint/EmptyClass: 66 | AllowComments: true 67 | Enabled: true 68 | Lint/EmptyInPattern: 69 | Enabled: true 70 | Lint/HashNewWithKeywordArgumentsAsDefault: 71 | Enabled: true 72 | Lint/IncompatibleIoSelectWithFiberScheduler: 73 | Enabled: true 74 | Lint/ItWithoutArgumentsInBlock: 75 | Enabled: true 76 | Lint/LambdaWithoutLiteralBlock: 77 | Enabled: true 78 | Lint/LiteralAssignmentInCondition: 79 | Enabled: true 80 | Lint/MixedCaseRange: 81 | Enabled: true 82 | Lint/NonAtomicFileOperation: 83 | Enabled: true 84 | Lint/NoReturnInBeginEndBlocks: 85 | Enabled: true 86 | Lint/NumberedParameterAssignment: 87 | Enabled: true 88 | Lint/NumericOperationWithConstantResult: 89 | Enabled: true 90 | Lint/OrAssignmentToConstant: 91 | Enabled: true 92 | Lint/RedundantDirGlobSort: 93 | Enabled: true 94 | Lint/RedundantRegexpQuantifiers: 95 | Enabled: true 96 | Lint/RedundantTypeConversion: 97 | Enabled: true 98 | Lint/RefinementImportMethods: 99 | Enabled: true 100 | Lint/RequireRangeParentheses: 101 | Enabled: true 102 | Lint/RequireRelativeSelfPath: 103 | Enabled: true 104 | Lint/SharedMutableDefault: 105 | Enabled: true 106 | Lint/SuppressedExceptionInNumberConversion: 107 | Enabled: true 108 | Lint/SymbolConversion: 109 | Enabled: true 110 | Lint/ToEnumArguments: 111 | Enabled: true 112 | Lint/TripleQuotes: 113 | Enabled: true 114 | Lint/UnescapedBracketInRegexp: 115 | Enabled: true 116 | Lint/UnexpectedBlockArity: 117 | Enabled: true 118 | Lint/UnmodifiedReduceAccumulator: 119 | Enabled: true 120 | Lint/UnusedMethodArgument: 121 | Exclude: 122 | - "lib/cloud_events/format.rb" 123 | Lint/UselessConstantScoping: 124 | Enabled: true 125 | Lint/UselessDefaultValueArgument: 126 | Enabled: true 127 | Lint/UselessDefined: 128 | Enabled: true 129 | Lint/UselessNumericOperation: 130 | Enabled: true 131 | Lint/UselessOr: 132 | Enabled: true 133 | Lint/UselessRescue: 134 | Enabled: true 135 | Lint/UselessRuby2Keywords: 136 | Enabled: true 137 | 138 | Metrics/AbcSize: 139 | Max: 30 140 | Metrics/BlockLength: 141 | Exclude: 142 | - "test/**/test_*.rb" 143 | Metrics/ClassLength: 144 | Max: 300 145 | Metrics/CollectionLiteralLength: 146 | Enabled: true 147 | Metrics/CyclomaticComplexity: 148 | Max: 12 149 | Metrics/MethodLength: 150 | Max: 25 151 | Metrics/ModuleLength: 152 | Max: 300 153 | Metrics/ParameterLists: 154 | Enabled: false 155 | Metrics/PerceivedComplexity: 156 | Max: 12 157 | 158 | Naming/BlockForwarding: 159 | Enabled: true 160 | Naming/PredicateMethod: 161 | Enabled: true 162 | 163 | Security/CompoundHash: 164 | Enabled: true 165 | Security/IoMethods: 166 | Enabled: true 167 | 168 | Style/AccessorGrouping: 169 | Enabled: false 170 | Style/AmbiguousEndlessMethodDefinition: 171 | Enabled: true 172 | Style/ArgumentsForwarding: 173 | Enabled: true 174 | Style/ArrayIntersect: 175 | Enabled: true 176 | Style/ArrayIntersectWithSingleElement: 177 | Enabled: true 178 | Style/BisectedAttrAccessor: 179 | Enabled: false 180 | Style/BitwisePredicate: 181 | Enabled: true 182 | Style/CaseEquality: 183 | Enabled: false 184 | Style/CollectionCompact: 185 | Enabled: true 186 | Style/CollectionQuerying: 187 | Enabled: true 188 | Style/CombinableDefined: 189 | Enabled: true 190 | Style/ComparableBetween: 191 | Enabled: false 192 | Style/ComparableClamp: 193 | Enabled: true 194 | Style/ConcatArrayLiterals: 195 | Enabled: true 196 | Style/DataInheritance: 197 | Enabled: true 198 | Style/DigChain: 199 | Enabled: true 200 | Style/DirEmpty: 201 | Enabled: true 202 | Style/DocumentDynamicEvalDefinition: 203 | Enabled: true 204 | Style/DocumentationMethod: 205 | Enabled: false 206 | Style/EmptyHeredoc: 207 | Enabled: true 208 | Style/EmptyStringInsideInterpolation: 209 | Enabled: true 210 | Style/EndlessMethod: 211 | Enabled: true 212 | Style/EnvHome: 213 | Enabled: true 214 | Style/ExactRegexpMatch: 215 | Enabled: true 216 | Style/FetchEnvVar: 217 | Enabled: true 218 | Style/FileEmpty: 219 | Enabled: true 220 | Style/FileNull: 221 | Enabled: true 222 | Style/FileRead: 223 | Enabled: true 224 | Style/FileTouch: 225 | Enabled: true 226 | Style/FileWrite: 227 | Enabled: true 228 | Style/GuardClause: 229 | Enabled: false 230 | Style/HashConversion: 231 | Enabled: true 232 | Style/HashExcept: 233 | Enabled: true 234 | Style/HashFetchChain: 235 | Enabled: true 236 | Style/HashSlice: 237 | Enabled: true 238 | Style/IfUnlessModifier: 239 | Enabled: false 240 | Style/IfWithBooleanLiteralBranches: 241 | Enabled: true 242 | Style/InPatternThen: 243 | Enabled: true 244 | Style/ItAssignment: 245 | Enabled: true 246 | Style/ItBlockParameter: 247 | Enabled: true 248 | Style/KeywordArgumentsMerging: 249 | Enabled: true 250 | Style/MagicCommentFormat: 251 | Enabled: true 252 | Style/MapCompactWithConditionalBlock: 253 | Enabled: true 254 | Style/MapIntoArray: 255 | Enabled: true 256 | Style/MapToHash: 257 | Enabled: true 258 | Style/MapToSet: 259 | Enabled: true 260 | Style/MethodCallWithArgsParentheses: 261 | Enabled: true 262 | Style/MinMaxComparison: 263 | Enabled: true 264 | Style/MultilineInPatternThen: 265 | Enabled: true 266 | Style/NegatedIfElseCondition: 267 | Enabled: true 268 | Style/Next: 269 | Enabled: false 270 | Style/NestedFileDirname: 271 | Enabled: true 272 | Style/NilLambda: 273 | Enabled: true 274 | Style/NumberedParameters: 275 | Enabled: true 276 | Style/NumberedParametersLimit: 277 | Enabled: true 278 | Style/ObjectThen: 279 | Enabled: true 280 | Style/OpenStructUse: 281 | Enabled: true 282 | Style/OperatorMethodCall: 283 | Enabled: true 284 | Style/OptionalBooleanParameter: 285 | Enabled: false 286 | Style/QuotedSymbols: 287 | Enabled: true 288 | Style/RedundantArgument: 289 | Enabled: true 290 | Style/RedundantArrayConstructor: 291 | Enabled: true 292 | Style/RedundantArrayFlatten: 293 | Enabled: true 294 | Style/RedundantConstantBase: 295 | Enabled: false 296 | Style/RedundantCurrentDirectoryInPath: 297 | Enabled: true 298 | Style/RedundantDoubleSplatHashBraces: 299 | Enabled: true 300 | Style/RedundantEach: 301 | Enabled: true 302 | Style/RedundantFilterChain: 303 | Enabled: true 304 | Style/RedundantFormat: 305 | Enabled: true 306 | Style/RedundantHeredocDelimiterQuotes: 307 | Enabled: true 308 | Style/RedundantInitialize: 309 | Enabled: true 310 | Style/RedundantInterpolationUnfreeze: 311 | Enabled: true 312 | Style/RedundantLineContinuation: 313 | Enabled: true 314 | Style/RedundantRegexpArgument: 315 | Enabled: true 316 | Style/RedundantRegexpConstructor: 317 | Enabled: true 318 | Style/RedundantSelfAssignmentBranch: 319 | Enabled: true 320 | Style/RedundantStringEscape: 321 | Enabled: true 322 | Style/RescueModifier: 323 | Enabled: false 324 | Style/ReturnNilInPredicateMethodDefinition: 325 | Enabled: true 326 | Style/SafeNavigationChainLength: 327 | Enabled: true 328 | Style/SelectByRegexp: 329 | Enabled: true 330 | Style/SendWithLiteralMethodName: 331 | Enabled: true 332 | Style/SingleLineDoEndBlock: 333 | Enabled: true 334 | Style/SoleNestedConditional: 335 | Enabled: false 336 | Style/StringChars: 337 | Enabled: true 338 | Style/StringLiterals: 339 | EnforcedStyle: double_quotes 340 | Style/SuperArguments: 341 | Enabled: true 342 | Style/SuperWithArgsParentheses: 343 | Enabled: true 344 | Style/SwapValues: 345 | Enabled: true 346 | Style/SymbolArray: 347 | EnforcedStyle: brackets 348 | Style/TrailingCommaInArrayLiteral: 349 | EnforcedStyleForMultiline: comma 350 | Style/TrailingCommaInHashLiteral: 351 | EnforcedStyleForMultiline: comma 352 | Style/WhileUntilModifier: 353 | Enabled: false 354 | Style/WordArray: 355 | EnforcedStyle: brackets 356 | Style/YAMLFileRead: 357 | Enabled: true 358 | -------------------------------------------------------------------------------- /.toys/.data/releases.yml: -------------------------------------------------------------------------------- 1 | repo: cloudevents/sdk-ruby 2 | git_user_name: CNCF CloudEvents Bot 3 | git_user_email: cncfcloudevents@gmail.com 4 | signoff_commits: true 5 | 6 | gems: 7 | - name: cloud_events 8 | gh_pages_version_var: version 9 | -------------------------------------------------------------------------------- /.toys/.toys.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | toys_version! ">= 0.17" 4 | 5 | expand :clean, paths: :gitignore 6 | 7 | expand :minitest, libs: ["lib"], bundler: true 8 | 9 | expand :rubocop, bundler: true 10 | 11 | expand :yardoc do |t| 12 | t.generate_output_flag = true 13 | t.fail_on_warning = true 14 | t.fail_on_undocumented_objects = true 15 | t.use_bundler 16 | end 17 | 18 | expand :gem_build 19 | 20 | expand :gem_build, name: "install", install_gem: true 21 | 22 | load_gem "toys-release", version: "~> 0.2", as: "release" 23 | -------------------------------------------------------------------------------- /.toys/ci.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | load_git remote: "https://github.com/dazuma/toys.git", 4 | path: "common-tools/ci", 5 | update: 3600 6 | 7 | desc "Run all CI checks" 8 | 9 | expand("toys-ci") do |toys_ci| 10 | toys_ci.only_flag = true 11 | toys_ci.fail_fast_flag = true 12 | toys_ci.job("Bundle update", flag: :bundle, exec: ["bundle", "update"]) 13 | toys_ci.job("Rubocop", flag: :rubocop, tool: ["rubocop"]) 14 | toys_ci.job("Tests", flag: :test, tool: ["test"]) 15 | toys_ci.job("Cucumber", flag: :cucumber, tool: ["cucumber"]) 16 | toys_ci.job("Yardoc", flag: :yard, tool: ["yardoc"]) 17 | toys_ci.job("Gem build", flag: :build, tool: ["build"]) 18 | end 19 | -------------------------------------------------------------------------------- /.toys/cucumber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | toys_version! ">= 0.16" 4 | 5 | desc "Run cucumber tests" 6 | 7 | remaining_args :features 8 | 9 | include :bundler 10 | include :exec, e: true 11 | include :git_cache 12 | include :fileutils 13 | 14 | def run 15 | setup_features 16 | cmd = ["cucumber", "--publish-quiet"] 17 | cmd += (verbosity > 0 ? ["--format=pretty"] : ["--format=progress"]) 18 | cmd += features 19 | exec cmd 20 | end 21 | 22 | def setup_features 23 | remote_features = git_cache.find("https://github.com/cloudevents/conformance", path: "features") 24 | local_features = File.join(context_directory, "features", "conformance") 25 | rm_rf(local_features) 26 | cp_r(remote_features, local_features) 27 | end 28 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --no-private 2 | --title=CloudEvents SDK 3 | --markup markdown 4 | --markup-provider redcarpet 5 | --main=README.md 6 | ./lib/cloud_events/**/*.rb 7 | ./lib/cloud_events.rb 8 | - 9 | README.md 10 | CHANGELOG.md 11 | LICENSE 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### v0.8.2 / 2025-11-30 4 | 5 | * DOCS: Fixed a few typos in documentation and error messages 6 | 7 | ### v0.8.1 / 2025-11-10 8 | 9 | * DOCS: Some minor updates and corrections to the README and examples 10 | 11 | ### v0.8.0 / 2025-11-04 12 | 13 | * BREAKING CHANGE: Raise AttributeError if an illegal attribute name is used 14 | * ADDED: Require Ruby 2.7 or later 15 | * FIXED: Improved hashing algorithm for opaque event objects 16 | * FIXED: Removed dependency on base64 gem 17 | * FIXED: Raise AttributeError if an illegal attribute name is used 18 | * DOCS: Add link to the security mailing list 19 | 20 | ### v0.7.1 / 2023-10-04 21 | 22 | * DOCS: Governance docs per CE PR 1226 23 | 24 | ### v0.7.0 / 2022-01-14 25 | 26 | * HttpBinding#probable_event? returns false if the request method is GET or HEAD. 27 | * HttpBinding#decode_event raises NotCloudEventError if the request method is GET or HEAD. 28 | * Fixed a NoMethodError if nil content was passed to the ContentType constructor. 29 | 30 | ### v0.6.0 / 2021-08-23 31 | 32 | This update further clarifies and cleans up the encoding behavior of event payloads. In particular, the event object now includes explicitly encoded data in the new `data_encoded` field, and provides information on whether the existing `data` field contains an encoded or decoded form of the payload. 33 | 34 | * Added `data_encoded`, `data_decoded?` and `data?` methods to `CloudEvents::Event::V1`, added `:data_encoded` as an input attribute, and clarified the encoding semantics of each field. 35 | * Changed `:attributes` keyword argument in event constructors to `:set_attributes`, to avoid any possible collision with a real extension attribute name. (The old argument name is deprecated and will be removed in 1.0.) 36 | * Fixed various inconsistencies in the data encoding behavior of `JsonFormat` and `HttpBinding`. 37 | * Support passing a data content encoder/decoder into `JsonFormat#encode_event` and `JsonFormat#decode_event`. 38 | * Provided `TextFormat` to handle media types with trivial encoding. 39 | * Provided `Format::Multi` to handle checking a series of encoders/decoders. 40 | 41 | ### v0.5.1 / 2021-06-28 42 | 43 | * ADDED: Add HttpBinding#probable_event? 44 | * FIXED: Fixed a NoMethodError when a format declined to decode an http request 45 | 46 | ### v0.5.0 / 2021-06-28 47 | 48 | This is a significant update that provides several important spec-related and usability fixes. Some of the behavioral changes are breaking, so to preserve compatibility, new methods were added and old methods deprecated, particularly in the HttpBinding class. Additionally, the formatter interface has been simplified and expanded to support payload formatting. 49 | 50 | * CHANGED: Deprecated HttpBinding#decode_rack_env and replaced with HttpBinding#decode_event. (The old method remains for backward compatibility, but will be removed in version 1.0). The new decode_event method has the following differences: 51 | * decode_event raises NotCloudEventError (rather than returning nil) if given an HTTP request that does not seem to have been intended as a CloudEvent. 52 | * decode_event takes an allow_opaque argument that, when set to true, returns a CloudEvents::Event::Opaque (rather than raising an exception) if given a structured event with a format not known by the SDK. Opaque event objects cannot have their fields inspected, but can be reserialized for retransmission to another event handler. 53 | * decode_event in binary content mode now parses JSON content-types and exposes the data attribute as a JSON value. 54 | * CHANGED: Deprecated the HttpBinding encoding entrypoints (encode_structured_content, encode_batched_content, and encode_binary_content) and replaced with a single encode_event entrypoint that handles all cases via the structured_format argument. (The old methods remain for backward compatibility, but will be removed in version 1.0). In addition, the new encode_event method has the following differences: 55 | * encode_event in binary content mode now interprets a string-valued data attribute as a JSON string and serializes it (i.e. wraps it in quotes) if the data_content_type has a JSON format. This is for compatibility with the behavior of the JSON structured mode which always treats the data attribute as a JSON value if the data_content_type indicates JSON. 56 | * CHANGED: The JsonFormat class interface was reworked to be more generic, combine the structured and batched calls, and add calls to handle data payloads. A Format module has been added to specify the interface used by JsonFormat and future formatters. (Breaking change of internal interfaces) 57 | * CHANGED: Renamed HttpContentError to UnsupportedFormatError to better reflect the specific issue being reported, and to eliminate the coupling to http. The old name remains aliased to the new name, but is deprecated and will be removed in version 1.0. 58 | * CHANGED: If format-driven parsing (e.g. parsing a JSON document) fails, a FormatSyntaxError will be raised instead of, for example, a JSON-specific error. The lower-level parser error can still be accessed from the exception's "cause". 59 | * FIXED: JsonFormat now sets the datacontenttype attribute explicitly to "application/json" if it isn't otherwise set. 60 | 61 | ### v0.4.0 / 2021-05-26 62 | 63 | * ADDED: ContentType can take an optional default charset 64 | * FIXED: Binary HTTP format parses quoted tokens according to RFC 7230 section 3.2.6 65 | * FIXED: When encoding structured events for HTTP transport, the content-type now includes the charset 66 | 67 | ### v0.3.1 / 2021-04-25 68 | 69 | * FIXED: Fixed exception when decoding from a rack source that uses InputWrapper 70 | * FIXED: Fixed equality checking for V0 events 71 | 72 | ### v0.3.0 / 2021-03-02 73 | 74 | * ADDED: Require Ruby 2.5 or later 75 | * FIXED: Deep-duplicated event attributes in to_h to avoid returning frozen objects 76 | 77 | ### v0.2.0 / 2021-01-25 78 | 79 | * ADDED: Freeze event objects to make them Ractor-shareable 80 | * DOCS: Fix formatting of Apache license 81 | 82 | ### v0.1.2 / 2020-09-02 83 | 84 | * Fix: Convert extension attributes to strings, and ignore nils 85 | * Documentation: Add code of conduct link to readme 86 | 87 | ### v0.1.1 / 2020-07-20 88 | 89 | * Updated a few documentation links. No functional changes. 90 | 91 | ### v0.1.0 / 2020-07-08 92 | 93 | * Initial release of the Ruby SDK. 94 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to CloudEvents' Ruby SDK 2 | 3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 4 | 5 | We welcome contributions from the community! Please take some time to become 6 | acquainted with the process before submitting a pull request. There are just 7 | a few things to keep in mind. 8 | 9 | # Pull Requests 10 | 11 | Typically, a pull request should relate to an existing issue. If you have 12 | found a bug, want to add an improvement, or suggest an API change, please 13 | create an issue before proceeding with a pull request. For very minor changes 14 | such as typos in the documentation this isn't really necessary. 15 | 16 | ## Pull Request Guidelines 17 | 18 | Here you will find step by step guidance for creating, submitting and updating 19 | a pull request in this repository. We hope it will help you have an easy time 20 | managing your work and a positive, satisfying experience when contributing 21 | your code. Thanks for getting involved! :rocket: 22 | 23 | * [Getting Started](#getting-started) 24 | * [Branches](#branches) 25 | * [Commit Messages](#commit-messages) 26 | * [Staying current with main](#staying-current-with-main) 27 | * [Submitting and Updating a Pull Request](#submitting-and-updating-a-pull-request) 28 | * [Congratulations!](#congratulations) 29 | 30 | ## Getting Started 31 | 32 | When creating a pull request, first fork this repository and clone it to your 33 | local development environment. Then add this repository as the upstream. 34 | 35 | ```console 36 | git clone https://github.com/mygithuborg/sdk-ruby.git 37 | cd sdk-ruby 38 | git remote add upstream https://github.com/cloudevents/sdk-ruby.git 39 | ``` 40 | 41 | ## Branches 42 | 43 | The first thing you'll need to do is create a branch for your work. 44 | If you are submitting a pull request that fixes or relates to an existing 45 | GitHub issue, you can use the issue number in your branch name to keep things 46 | organized. 47 | 48 | ```console 49 | git fetch upstream 50 | git reset --hard upstream/main 51 | git checkout FETCH_HEAD 52 | git checkout -b 48-fix-http-agent-error 53 | ``` 54 | 55 | ## Commit Messages 56 | 57 | Please follow the 58 | [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/#summary). 59 | The first line of your commit should be prefixed with a type, be a single 60 | sentence with no period, and succinctly indicate what this commit changes. 61 | 62 | All commit message lines should be kept to fewer than 80 characters if possible. 63 | 64 | An example of a good commit message. 65 | 66 | ```log 67 | docs: remove 0.1, 0.2 spec support from README 68 | ``` 69 | 70 | ### Signing your commits 71 | 72 | Each commit must be signed. Use the `--signoff` flag for your commits. 73 | 74 | ```console 75 | git commit --signoff 76 | ``` 77 | 78 | This will add a line to every git commit message: 79 | 80 | Signed-off-by: Joe Smith 81 | 82 | Use your real name (sorry, no pseudonyms or anonymous contributions.) 83 | 84 | The sign-off is a signature line at the end of your commit message. Your 85 | signature certifies that you wrote the patch or otherwise have the right to pass 86 | it on as open-source code. See [developercertificate.org](http://developercertificate.org/) 87 | for the full text of the certification. 88 | 89 | Be sure to have your `user.name` and `user.email` set in your git config. 90 | If your git config information is set properly then viewing the `git log` 91 | information for your commit will look something like this: 92 | 93 | ``` 94 | Author: Joe Smith 95 | Date: Thu Feb 2 11:41:15 2018 -0800 96 | 97 | Update README 98 | 99 | Signed-off-by: Joe Smith 100 | ``` 101 | 102 | Notice the `Author` and `Signed-off-by` lines match. If they don't your PR will 103 | be rejected by the automated DCO check. 104 | 105 | ## Staying Current with `main` 106 | 107 | As you are working on your branch, changes may happen on `main`. Before 108 | submitting your pull request, be sure that your branch has been updated 109 | with the latest commits. 110 | 111 | ```console 112 | git fetch upstream 113 | git rebase upstream/main 114 | ``` 115 | 116 | This may cause conflicts if the files you are changing on your branch are 117 | also changed on main. Error messages from `git` will indicate if conflicts 118 | exist and what files need attention. Resolve the conflicts in each file, then 119 | continue with the rebase with `git rebase --continue`. 120 | 121 | 122 | If you've already pushed some changes to your `origin` fork, you'll 123 | need to force push these changes. 124 | 125 | ```console 126 | git push -f origin 48-fix-http-agent-error 127 | ``` 128 | 129 | ## Submitting and Updating Your Pull Request 130 | 131 | Before submitting a pull request, you should make sure that all of the tests 132 | successfully pass. 133 | 134 | Once you have sent your pull request, `main` may continue to evolve 135 | before your pull request has landed. If there are any commits on `main` 136 | that conflict with your changes, you may need to update your branch with 137 | these changes before the pull request can land. Resolve conflicts the same 138 | way as before. 139 | 140 | ```console 141 | git fetch upstream 142 | git rebase upstream/main 143 | # fix any potential conflicts 144 | git push -f origin 48-fix-http-agent-error 145 | ``` 146 | 147 | This will cause the pull request to be updated with your changes, and 148 | CI will rerun. 149 | 150 | A maintainer may ask you to make changes to your pull request. Sometimes these 151 | changes are minor and shouldn't appear in the commit log. For example, you may 152 | have a typo in one of your code comments that should be fixed before merge. 153 | You can prevent this from adding noise to the commit log with an interactive 154 | rebase. See the [git documentation](https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History) 155 | for details. 156 | 157 | ```console 158 | git commit -m "fixup: fix typo" 159 | git rebase -i upstream/main # follow git instructions 160 | ``` 161 | 162 | Once you have rebased your commits, you can force push to your fork as before. 163 | 164 | ## Congratulations! 165 | 166 | Congratulations! You've done it! We really appreciate the time and energy 167 | you've given to the project. Thank you. 168 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | gemspec 5 | 6 | gem "cucumber", "~> 9.2" 7 | gem "logger", "~> 1.4" 8 | gem "minitest", "~> 5.25" 9 | gem "minitest-focus", "~> 1.4" 10 | gem "minitest-rg", "~> 5.3" 11 | gem "rack", "~> 3.2" 12 | gem "redcarpet", "~> 3.6" unless ::RUBY_PLATFORM == "java" 13 | gem "rubocop", "~> 1.81" 14 | gem "toys-core", "~> 0.17" 15 | gem "webrick", "~> 1.9" 16 | gem "yard", "~> 0.9.37" 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # Maintainers 2 | 3 | Current active maintainers of this SDK: 4 | 5 | - [Daniel Azuma](https://github.com/dazuma) 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ruby SDK for [CloudEvents](https://github.com/cloudevents/spec) 2 | 3 | ## CloudEvents Ruby SDK 4 | 5 | A [Ruby](https://ruby-lang.org) language implementation of the 6 | [CloudEvents specification](https://github.com/cloudevents/spec). 7 | 8 | Features: 9 | 10 | * Ruby classes for representing CloudEvents, including support for standard 11 | and extension attributes. 12 | * Support for serializing and deserializing from JSON Structure Format and 13 | JSON Batch Format. 14 | * Support for sending and receiving CloudEvents via HTTP Bindings. 15 | * Supports the [CloudEvent 0.3](https://github.com/cloudevents/spec/tree/v0.3) 16 | and [CloudEvents 1.0](https://github.com/cloudevents/spec/tree/v1.0.2) 17 | specifications. 18 | * Extensible to additional formats and protocol bindings, and future 19 | specification versions. 20 | * Compatible with Ruby 2.7 or later, or JRuby 9.2.x or later. No runtime gem 21 | dependencies. 22 | 23 | ## Quickstart 24 | 25 | Install the `cloud_events` gem or add it to your bundle. 26 | 27 | ```sh 28 | gem install cloud_events 29 | ``` 30 | 31 | ### Receiving a CloudEvent in a Sinatra app 32 | 33 | A simple [Sinatra](https://sinatrarb.com) app that receives CloudEvents: 34 | 35 | ```ruby 36 | # examples/server/Gemfile 37 | source "https://rubygems.org" 38 | gem "cloud_events", "~> 0.8" 39 | gem "sinatra", "~> 4.0" 40 | gem "rackup" 41 | gem "puma" 42 | ``` 43 | 44 | ```ruby 45 | # examples/server/app.rb 46 | require "sinatra" 47 | require "cloud_events" 48 | 49 | cloud_events_http = CloudEvents::HttpBinding.default 50 | 51 | post("/") do 52 | event = cloud_events_http.decode_event(request.env) 53 | logger.info("Received CloudEvent: #{event.to_h}") 54 | end 55 | ``` 56 | 57 | ### Sending a CloudEvent 58 | 59 | A simple Ruby script that sends a CloudEvent: 60 | 61 | ```ruby 62 | # examples/client/Gemfile 63 | source "https://rubygems.org" 64 | gem "cloud_events", "~> 0.8" 65 | ``` 66 | 67 | ```ruby 68 | # examples/client/send.rb 69 | require "cloud_events" 70 | require "net/http" 71 | require "uri" 72 | 73 | data = { message: "Hello, CloudEvents!" } 74 | event = CloudEvents::Event.create( 75 | spec_version: "1.0", 76 | id: "1234-1234-1234", 77 | source: "/mycontext", 78 | type: "com.example.someevent", 79 | data_content_type: "application/json", 80 | data: data 81 | ) 82 | 83 | cloud_events_http = CloudEvents::HttpBinding.default 84 | headers, body = cloud_events_http.encode_event(event) 85 | Net::HTTP.post(URI("http://localhost:4567"), body, headers) 86 | ``` 87 | 88 | ### Putting it together 89 | 90 | Start the server on localhost: 91 | 92 | ```sh 93 | cd server 94 | bundle install 95 | bundle exec ruby app.rb 96 | ``` 97 | 98 | This will run the server in the foreground and start logging to the console. 99 | 100 | In a separate terminal shell, send it an event from the client: 101 | 102 | ```sh 103 | cd client 104 | bundle install 105 | bundle exec ruby send.rb 106 | ``` 107 | 108 | The event should be logged in the server logs. 109 | 110 | Hit `CTRL+C` to stop the server. 111 | 112 | ## Contributing 113 | 114 | Bug reports and pull requests are welcome on GitHub at 115 | https://github.com/cloudevents/sdk-ruby. 116 | 117 | ### Development 118 | 119 | After cloning the repo locally, install the bundle, and install the `toys` gem 120 | if you do not already have it. 121 | 122 | ```sh 123 | bundle install 124 | gem install toys 125 | ``` 126 | 127 | A variety of Toys scripts are provided for running tests and builds. For 128 | example: 129 | 130 | ```sh 131 | # Run the unit tests 132 | toys test 133 | 134 | # Run CI locally, including unit tests, doc tests, and rubocop 135 | toys ci 136 | 137 | # Build and install the gem locally 138 | toys install 139 | 140 | # Clean temporary and build files 141 | toys clean 142 | 143 | # List all available scripts 144 | toys 145 | 146 | # Show online help for the "test" script 147 | toys test --help 148 | ``` 149 | 150 | ### Code style 151 | 152 | Ruby code style is enforced by Rubocop rules. We've left the configuration 153 | largely on the Rubocop defaults, with a few exceptions, notably: 154 | 155 | * We prefer double-quoted strings rather than single-quoted strings. 156 | * We prefer trailing commas in multi-line array and hash literals. 157 | * Line length limit is 120 158 | * We've loosened a few additional checks that we felt were not helpful. 159 | 160 | You can run rubocop directly using the 161 | `rubocop` binary: 162 | 163 | ```sh 164 | bundle exec rubocop 165 | ``` 166 | 167 | or via Toys: 168 | 169 | ```sh 170 | toys rubocop 171 | ``` 172 | 173 | That said, we are not style sticklers, and if a break is necessary for code 174 | readability or practicality, Rubocop rules can be selectively disabled. 175 | 176 | ### Pull requests 177 | 178 | We welcome contributions from the community! Please take some time to become 179 | acquainted with the process before submitting a pull request. There are just a 180 | few things to keep in mind. 181 | 182 | * **Typically a pull request should relate to an existing issue.** If you 183 | have found a bug, want to add an improvement, or suggest an API change, 184 | please create an issue before proceeding with a pull request. For very 185 | minor changes such as typos in the documentation this isn't necessary. 186 | * **Use Conventional Commit messages.** All commit messages should follow the 187 | [Conventional Commits Specification](https://conventionalcommits.org) to 188 | make it clear how your change should appear in release notes. 189 | * **Sign your work.** Each PR must be signed. Be sure your git `user.name` 190 | and `user.email` are configured then use the `--signoff` flag for your 191 | commits. e.g. `git commit --signoff`. 192 | * **Make sure CI passes.** Invoke `toys ci` to run the tests locally before 193 | opening a pull request. This will include code style checks. 194 | 195 | ### For more information 196 | 197 | * Library documentation: https://cloudevents.github.io/sdk-ruby 198 | * Issue tracker: https://github.com/cloudevents/sdk-ruby/issues 199 | * Changelog: https://cloudevents.github.io/sdk-ruby/latest/file.CHANGELOG.html 200 | 201 | ## Community 202 | 203 | * **Weekly meetings:** There are bi-weekly calls immediately following the 204 | [Serverless/CloudEvents call](https://github.com/cloudevents/spec#meeting-time) 205 | at 9am PT (US Pacific). Which means they will typically start at 10am PT, 206 | but if the other call ends early then the SDK call will start early as 207 | well. See the 208 | [CloudEvents meeting minutes](https://docs.google.com/document/d/1OVF68rpuPK5shIHILK9JOqlZBbfe91RNzQ7u_P7YCDE/edit) 209 | to determine which week will have the call. 210 | 211 | * **Slack:** The `#cloudeventssdk` channel under 212 | [CNCF's Slack workspace](https://slack.cncf.io/). 213 | 214 | * **Email:** https://lists.cncf.io/g/cncf-cloudevents-sdk 215 | 216 | * For additional information, contact Daniel Azuma (`@dazuma` on Slack). 217 | 218 | Each SDK may have its own unique processes, tooling and guidelines, common 219 | governance related material can be found in the 220 | [CloudEvents `community`](https://github.com/cloudevents/spec/tree/master/community) 221 | directory. In particular, in there you will find information concerning 222 | how SDK projects are 223 | [managed](https://github.com/cloudevents/spec/blob/master/community/SDK-GOVERNANCE.md), 224 | [guidelines](https://github.com/cloudevents/spec/blob/master/community/SDK-maintainer-guidelines.md) 225 | for how PR reviews and approval, and our 226 | [Code of Conduct](https://github.com/cloudevents/spec/blob/master/community/GOVERNANCE.md#additional-information) 227 | information. 228 | 229 | If there is a security concern with one of the CloudEvents specifications, or 230 | with one of the project's SDKs, please send an email to 231 | [cncf-cloudevents-security@lists.cncf.io](mailto:cncf-cloudevents-security@lists.cncf.io). 232 | 233 | ## Additional SDK Resources 234 | 235 | - [List of current active maintainers](MAINTAINERS.md) 236 | - [How to contribute to the project](CONTRIBUTING.md) 237 | - [SDK's License](LICENSE) 238 | - [SDK's Release process](RELEASING.md) 239 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | Releases can be performed only by users with write access to the repository. 4 | 5 | To perform a release: 6 | 7 | 1. Go to the GitHub Actions tab, and launch the "Request Release" workflow. 8 | You can leave the input field blank. 9 | 10 | 2. The workflow will analyze the commit messages since the last release, and 11 | open a pull request with a new version and a changelog entry. You can 12 | optionally edit this pull request to modify the changelog or change the 13 | version released. 14 | 15 | 3. Merge the pull request (keeping the `release: pending` label set.) Once the 16 | CI tests have run successfully, a job will run automatically to perform the 17 | release, including tagging the commit in git, building and releasing a gem, 18 | and building and pushing documentation. 19 | 20 | These tasks can also be performed manually by running the appropriate scripts 21 | locally. See `toys release request --help` and `toys release perform --help` 22 | for more information. 23 | 24 | If a release fails, you may need to delete the release tag before retrying. 25 | -------------------------------------------------------------------------------- /cloud_events.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/cloud_events/version" 4 | version = ::CloudEvents::VERSION 5 | 6 | ::Gem::Specification.new do |spec| 7 | spec.name = "cloud_events" 8 | spec.version = version 9 | spec.licenses = ["Apache-2.0"] 10 | spec.authors = ["Daniel Azuma"] 11 | spec.email = ["dazuma@gmail.com"] 12 | 13 | spec.summary = "Ruby SDK for CloudEvents" 14 | spec.description = 15 | "The official Ruby implementation of the CloudEvents Specification. " \ 16 | "Provides data types for events, and HTTP/JSON bindings for marshalling " \ 17 | "and unmarshalling event data." 18 | spec.homepage = "https://github.com/cloudevents/sdk-ruby" 19 | 20 | spec.files = ::Dir.glob("lib/**/*.rb") + ::Dir.glob("*.md") + [".yardopts"] 21 | spec.require_paths = ["lib"] 22 | spec.required_ruby_version = ">= 2.7" 23 | 24 | if spec.respond_to?(:metadata) 25 | spec.metadata["changelog_uri"] = "https://cloudevents.github.io/sdk-ruby/v#{version}/file.CHANGELOG.html" 26 | spec.metadata["source_code_uri"] = "https://github.com/cloudevents/sdk-ruby" 27 | spec.metadata["bug_tracker_uri"] = "https://github.com/cloudevents/sdk-ruby/issues" 28 | spec.metadata["documentation_uri"] = "https://cloudevents.github.io/sdk-ruby/v#{version}" 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /examples/client/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "cloud_events", path: "../.." 6 | -------------------------------------------------------------------------------- /examples/client/send.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cloud_events" 4 | require "net/http" 5 | require "uri" 6 | 7 | data = { message: "Hello, CloudEvents!" } 8 | event = CloudEvents::Event.create( 9 | spec_version: "1.0", 10 | id: "1234-1234-1234", 11 | source: "/mycontext", 12 | type: "com.example.someevent", 13 | data_content_type: "application/json", 14 | data: data 15 | ) 16 | 17 | cloud_events_http = CloudEvents::HttpBinding.default 18 | headers, body = cloud_events_http.encode_event(event) 19 | Net::HTTP.post(URI("http://localhost:4567"), body, headers) 20 | -------------------------------------------------------------------------------- /examples/server/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "cloud_events", path: "../.." 6 | gem "puma" 7 | gem "rackup" 8 | gem "sinatra", "~> 4.0" 9 | -------------------------------------------------------------------------------- /examples/server/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sinatra" 4 | require "cloud_events" 5 | 6 | cloud_events_http = CloudEvents::HttpBinding.default 7 | 8 | post("/") do 9 | event = cloud_events_http.decode_event(request.env) 10 | logger.info("Received CloudEvent: #{event.to_h}") 11 | end 12 | -------------------------------------------------------------------------------- /features/step_definitions/steps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webrick" 4 | require "stringio" 5 | require "rack" 6 | 7 | Given "HTTP Protocol Binding is supported" do 8 | @http_binding = CloudEvents::HttpBinding.default 9 | end 10 | 11 | Given "an HTTP request" do |str| 12 | # WEBrick parsing wants the lines delimited by \r\n, but the input 13 | # content-length assumes \n within the body. 14 | parts = str.split("\n\n") 15 | parts[0].gsub!("\n", "\r\n") 16 | str = "#{parts[0]}\r\n\r\n#{parts[1]}" 17 | webrick_request = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP) 18 | webrick_request.parse(StringIO.new(str)) 19 | @rack_request = {} 20 | @rack_request[Rack::REQUEST_METHOD] = webrick_request.request_method 21 | @rack_request[Rack::SCRIPT_NAME] = webrick_request.script_name 22 | @rack_request[Rack::PATH_INFO] = webrick_request.path_info 23 | @rack_request[Rack::QUERY_STRING] = webrick_request.query_string 24 | @rack_request[Rack::SERVER_NAME] = webrick_request.server_name 25 | @rack_request[Rack::SERVER_PORT] = webrick_request.port 26 | @rack_request[Rack::RACK_VERSION] = Rack::VERSION 27 | @rack_request[Rack::RACK_URL_SCHEME] = webrick_request.ssl? ? "https" : "http" 28 | @rack_request[Rack::RACK_INPUT] = StringIO.new(webrick_request.body) 29 | @rack_request[Rack::RACK_ERRORS] = StringIO.new 30 | webrick_request.each do |key, value| 31 | key = key.upcase.tr("-", "_") 32 | key = "HTTP_#{key}" unless key == "CONTENT_TYPE" 33 | @rack_request[key] = value 34 | end 35 | end 36 | 37 | When "parsed as HTTP request" do 38 | @event = @http_binding.decode_event(@rack_request) 39 | end 40 | 41 | Then "the attributes are:" do |table| 42 | table.hashes.each do |hash| 43 | assert_equal hash["value"], @event[hash["key"]] 44 | end 45 | end 46 | 47 | Then "the data is equal to the following JSON:" do |str| 48 | json = JSON.parse(str) 49 | assert_equal json, @event.data 50 | end 51 | 52 | Given "Kafka Protocol Binding is supported" do 53 | pending "Kafka Protocol Binding is not yet implemented" 54 | end 55 | 56 | Given "a Kafka message with payload:" do |_str| 57 | pending 58 | end 59 | 60 | Given "Kafka headers:" do |_table| 61 | pending 62 | end 63 | 64 | When "parsed as Kafka message" do 65 | pending 66 | end 67 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | dir = ::File.expand_path("../../lib", __dir__) 4 | $LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir) 5 | require "cloud_events" 6 | -------------------------------------------------------------------------------- /lib/cloud_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cloud_events/content_type" 4 | require "cloud_events/errors" 5 | require "cloud_events/event" 6 | require "cloud_events/format" 7 | require "cloud_events/http_binding" 8 | require "cloud_events/json_format" 9 | require "cloud_events/text_format" 10 | 11 | ## 12 | # CloudEvents implementation. 13 | # 14 | # This is a Ruby implementation of the [CloudEvents](https://cloudevents.io) 15 | # specification. It supports both 16 | # [CloudEvents 0.3](https://github.com/cloudevents/spec/blob/v0.3/spec.md) and 17 | # [CloudEvents 1.0](https://github.com/cloudevents/spec/blob/v1.0/spec.md). 18 | # 19 | module CloudEvents 20 | # @private 21 | SUPPORTED_SPEC_VERSIONS = ["0.3", "1.0"].freeze 22 | 23 | class << self 24 | ## 25 | # The spec versions supported by this implementation. 26 | # 27 | # @return [Array] 28 | # 29 | def supported_spec_versions 30 | SUPPORTED_SPEC_VERSIONS 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/cloud_events/content_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudEvents 4 | ## 5 | # A parsed content-type header. 6 | # 7 | # This object represents the information contained in a Content-Type, 8 | # obtained by parsing the header according to RFC 2045. 9 | # 10 | # Case-insensitive fields, such as media_type and subtype, are normalized 11 | # to lower case. 12 | # 13 | # If parsing fails, this class will try to get as much information as it 14 | # can, and fill the rest with defaults as recommended in RFC 2045 sec 5.2. 15 | # In case of a parsing error, the {#error_message} field will be set. 16 | # 17 | # This object is immutable, and Ractor-shareable on Ruby 3. 18 | # 19 | class ContentType 20 | ## 21 | # Parse the given header value. 22 | # 23 | # @param string [String] Content-Type header value in RFC 2045 format 24 | # @param default_charset [String] Optional. The charset to use if none is 25 | # specified. Defaults to `us-ascii`. 26 | # 27 | def initialize(string, default_charset: nil) 28 | @string = string.to_s 29 | @media_type = "text" 30 | @subtype_base = @subtype = "plain" 31 | @subtype_format = nil 32 | @params = [] 33 | @charset = default_charset || "us-ascii" 34 | @error_message = nil 35 | parse(consume_comments(@string.strip)) 36 | @canonical_string = "#{@media_type}/#{@subtype}" + 37 | @params.map { |k, v| "; #{k}=#{maybe_quote(v)}" }.join 38 | full_freeze 39 | end 40 | 41 | ## 42 | # The original header content string. 43 | # @return [String] 44 | # 45 | attr_reader :string 46 | alias to_s string 47 | 48 | ## 49 | # A "canonical" header content string with spacing and capitalization 50 | # normalized. 51 | # @return [String] 52 | # 53 | attr_reader :canonical_string 54 | 55 | ## 56 | # The media type. 57 | # @return [String] 58 | # 59 | attr_reader :media_type 60 | 61 | ## 62 | # The entire content subtype (which could include an extension delimited 63 | # by a plus sign). 64 | # @return [String] 65 | # 66 | attr_reader :subtype 67 | 68 | ## 69 | # The portion of the content subtype before any plus sign. 70 | # @return [String] 71 | # 72 | attr_reader :subtype_base 73 | 74 | ## 75 | # The portion of the content subtype after any plus sign, or nil if there 76 | # is no plus sign in the subtype. 77 | # @return [String,nil] 78 | # 79 | attr_reader :subtype_format 80 | 81 | ## 82 | # An array of parameters, each element as a two-element array of the 83 | # parameter name and value. 84 | # @return [Array] 85 | # 86 | attr_reader :params 87 | 88 | ## 89 | # The charset, defaulting to "us-ascii" if none is explicitly set. 90 | # @return [String] 91 | # 92 | attr_reader :charset 93 | 94 | ## 95 | # The error message when parsing, or `nil` if there was no error message. 96 | # @return [String,nil] 97 | # 98 | attr_reader :error_message 99 | 100 | ## 101 | # An array of values for the given parameter name 102 | # @param key [String] 103 | # @return [Array] 104 | # 105 | def param_values(key) 106 | key = key.downcase 107 | @params.inject([]) { |a, (k, v)| key == k ? a << v : a } 108 | end 109 | 110 | ## @private 111 | def ==(other) 112 | other.is_a?(ContentType) && canonical_string == other.canonical_string 113 | end 114 | alias eql? == 115 | 116 | ## @private 117 | def hash 118 | canonical_string.hash 119 | end 120 | 121 | ## @private 122 | class ParseError < ::StandardError 123 | end 124 | 125 | private 126 | 127 | def parse(str) 128 | @media_type, str = consume_token(str, downcase: true, error_message: "Failed to parse media type") 129 | str = consume_special(str, "/") 130 | @subtype, str = consume_token(str, downcase: true, error_message: "Failed to parse subtype") 131 | @subtype_base, @subtype_format = @subtype.split("+", 2) 132 | until str.empty? 133 | str = consume_special(str, ";") 134 | name, str = consume_token(str, downcase: true, error_message: "Failed to parse attribute name") 135 | str = consume_special(str, "=", error_message: "Failed to find value for attribute #{name}") 136 | val, str = consume_token_or_quoted(str, error_message: "Failed to parse value for attribute #{name}") 137 | @params << [name, val] 138 | @charset = val if name == "charset" 139 | end 140 | rescue ParseError => e 141 | @error_message = e.message 142 | end 143 | 144 | def consume_token(str, downcase: false, error_message: nil) 145 | match = /^([\w!#$%&'*+.\^`{|}-]+)(.*)$/.match(str) 146 | raise(ParseError, error_message || "Expected token") unless match 147 | token = match[1] 148 | token.downcase! if downcase 149 | str = consume_comments(match[2].strip) 150 | [token, str] 151 | end 152 | 153 | def consume_special(str, expected, error_message: nil) 154 | raise(ParseError, error_message || "Expected #{expected.inspect}") unless str.start_with?(expected) 155 | consume_comments(str[1..].strip) 156 | end 157 | 158 | def consume_token_or_quoted(str, error_message: nil) 159 | return consume_token(str) unless str.start_with?('"') 160 | arr = [] 161 | index = 1 162 | loop do 163 | char = str[index] 164 | case char 165 | when nil 166 | raise(ParseError, error_message || "Quoted-string never finished") 167 | when "\"" 168 | break 169 | when "\\" 170 | char = str[index + 1] 171 | raise(ParseError, error_message || "Quoted-string never finished") unless char 172 | arr << char 173 | index += 2 174 | else 175 | arr << char 176 | index += 1 177 | end 178 | end 179 | index += 1 180 | str = consume_comments(str[index..].strip) 181 | [arr.join, str] 182 | end 183 | 184 | def consume_comments(str) 185 | return str unless str.start_with?("(") 186 | index = 1 187 | loop do 188 | char = str[index] 189 | case char 190 | when nil 191 | raise(ParseError, "Comment never finished") 192 | when ")" 193 | break 194 | when "\\" 195 | index += 2 196 | when "(" 197 | str = consume_comments(str[index..]) 198 | index = 0 199 | else 200 | index += 1 201 | end 202 | end 203 | index += 1 204 | consume_comments(str[index..].strip) 205 | end 206 | 207 | def maybe_quote(str) 208 | return str if /^[\w!#$%&'*+.\^`{|}-]+$/ =~ str 209 | str = str.gsub("\\", "\\\\\\\\").gsub("\"", "\\\\\"") 210 | "\"#{str}\"" 211 | end 212 | 213 | def full_freeze 214 | instance_variables.each do |iv| 215 | instance_variable_get(iv).freeze 216 | end 217 | @params.each do |name_val| 218 | name_val[0].freeze 219 | name_val[1].freeze 220 | name_val.freeze 221 | end 222 | freeze 223 | end 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /lib/cloud_events/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudEvents 4 | ## 5 | # Base class for all CloudEvents errors. 6 | # 7 | class CloudEventsError < ::StandardError 8 | end 9 | 10 | ## 11 | # An error raised when a protocol binding was asked to decode a CloudEvent 12 | # from input data, but does not believe that the data was intended to be a 13 | # CloudEvent. For example, the HttpBinding might raise this exception if 14 | # given a request that has neither the requisite headers for binary content 15 | # mode, nor an appropriate content-type for structured content mode. 16 | # 17 | class NotCloudEventError < CloudEventsError 18 | end 19 | 20 | ## 21 | # An error raised when a protocol binding was asked to decode a CloudEvent 22 | # from input data, and the data appears to be a CloudEvent, but was encoded 23 | # in a format that is not supported. Some protocol bindings can be configured 24 | # to return a {CloudEvents::Event::Opaque} object instead of raising this 25 | # error. 26 | # 27 | class UnsupportedFormatError < CloudEventsError 28 | end 29 | 30 | ## 31 | # An error raised when a protocol binding was asked to decode a CloudEvent 32 | # from input data, and the data appears to be intended as a CloudEvent, but 33 | # has unrecoverable format or syntax errors. This error _may_ have a `cause` 34 | # such as a `JSON::ParserError` with additional information. 35 | # 36 | class FormatSyntaxError < CloudEventsError 37 | end 38 | 39 | ## 40 | # An error raised when a `specversion` is set to a value not recognized or 41 | # supported by the SDK. 42 | # 43 | class SpecVersionError < CloudEventsError 44 | end 45 | 46 | ## 47 | # An error raised when a malformed CloudEvents attribute is encountered, 48 | # often because a required attribute is missing, or a value does not match 49 | # the attribute's type specification. 50 | # 51 | class AttributeError < CloudEventsError 52 | end 53 | 54 | ## 55 | # Alias of UnsupportedFormatError, for backward compatibility. 56 | # 57 | # @deprecated Will be removed in version 1.0. Use {UnsupportedFormatError}. 58 | # 59 | HttpContentError = UnsupportedFormatError 60 | end 61 | -------------------------------------------------------------------------------- /lib/cloud_events/event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "date" 4 | require "uri" 5 | 6 | require "cloud_events/event/opaque" 7 | require "cloud_events/event/v0" 8 | require "cloud_events/event/v1" 9 | 10 | module CloudEvents 11 | ## 12 | # An Event object represents a complete CloudEvent, including both data and 13 | # context attributes. The following are true of all event objects: 14 | # 15 | # * Event classes are defined within this module. For example, events 16 | # conforming to the CloudEvents 1.0 specification are of type 17 | # {CloudEvents::Event::V1}. 18 | # * All event classes include this module itself, so you can use 19 | # `is_a? CloudEvents::Event` to test whether an object is an event. 20 | # * All event objects are immutable. Data and attribute values can be 21 | # retrieved but not modified. To "modify" an event, make a copy with 22 | # the desired changes. Generally, event classes will provide a helper 23 | # method for this purpose. 24 | # * All event objects have a `spec_version` method that returns the 25 | # version of the CloudEvents spec implemented by that event. (Other 26 | # methods may be different, depending on the spec version.) 27 | # 28 | # To create an event, you may either: 29 | # 30 | # * Construct an instance of the event class directly, for example by 31 | # calling {CloudEvents::Event::V1.new} and passing a set of attributes. 32 | # * Call {CloudEvents::Event.create} and pass a spec version and a set of 33 | # attributes. This will choose the appropriate event class based on the 34 | # version. 35 | # * Decode an event from another representation. For example, use 36 | # {CloudEvents::JsonFormat} to decode an event from JSON, or use 37 | # {CloudEvents::HttpBinding} to decode an event from an HTTP request. 38 | # 39 | # See https://github.com/cloudevents/spec for more information about 40 | # CloudEvents. The documentation for the individual event classes 41 | # {CloudEvents::Event::V0} and {CloudEvents::Event::V1} also include links to 42 | # their respective specifications. 43 | # 44 | module Event 45 | class << self 46 | ## 47 | # Create a new cloud event object with the given version. Generally, 48 | # you must also pass additional keyword arguments providing the event's 49 | # data and attributes. For example, if you pass `1.0` as the 50 | # `spec_version`, the remaining keyword arguments will be passed 51 | # through to the {CloudEvents::Event::V1} constructor. 52 | # 53 | # Note that argument objects passed in may get deep-frozen if they are 54 | # used in the final event object. This is particularly important for the 55 | # `:data` field, for example if you pass a structured hash. If this is an 56 | # issue, make a deep copy of objects before passing them to this method. 57 | # 58 | # @param spec_version [String] The required `specversion` field. 59 | # @param kwargs [keywords] Additional parameters for the event. 60 | # 61 | def create(spec_version:, **kwargs) 62 | case spec_version 63 | when "0.3" 64 | V0.new(spec_version: spec_version, **kwargs) 65 | when /^1(\.|$)/ 66 | V1.new(spec_version: spec_version, **kwargs) 67 | else 68 | raise(SpecVersionError, "Unrecognized specversion: #{spec_version}") 69 | end 70 | end 71 | alias new create 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/cloud_events/event/field_interpreter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cloud_events/event/utils" 4 | 5 | module CloudEvents 6 | module Event 7 | ## 8 | # A helper that extracts and interprets event fields from an input hash. 9 | # @private 10 | # 11 | class FieldInterpreter 12 | def initialize(args) 13 | @args = Utils.keys_to_strings(args) 14 | @attributes = {} 15 | end 16 | 17 | def finish_attributes(requires_lc_start: false) 18 | @args.each do |key, value| 19 | check_attribute_name(key, requires_lc_start) 20 | @attributes[key.freeze] = value.to_s.freeze unless value.nil? 21 | end 22 | @args = {} 23 | @attributes.freeze 24 | end 25 | 26 | def string(keys, required: false, allow_empty: false) 27 | object(keys, required: required) do |value| 28 | case value 29 | when ::String 30 | raise(AttributeError, "The #{keys.first} field cannot be empty") if value.empty? && !allow_empty 31 | value.freeze 32 | [value, value] 33 | else 34 | raise(AttributeError, "Illegal type for #{keys.first}: " \ 35 | "String expected but #{value.class} found") 36 | end 37 | end 38 | end 39 | 40 | def uri(keys, required: false) 41 | object(keys, required: required) do |value| 42 | case value 43 | when ::String 44 | raise(AttributeError, "The #{keys.first} field cannot be empty") if value.empty? 45 | begin 46 | [Utils.deep_freeze(::URI.parse(value)), value.freeze] 47 | rescue ::URI::InvalidURIError => e 48 | raise(AttributeError, "Illegal format for #{keys.first}: #{e.message}") 49 | end 50 | when ::URI::Generic 51 | [Utils.deep_freeze(value), value.to_s.freeze] 52 | else 53 | raise(AttributeError, "Illegal type for #{keys.first}: " \ 54 | "String or URI expected but #{value.class} found") 55 | end 56 | end 57 | end 58 | 59 | def rfc3339_date_time(keys, required: false) 60 | object(keys, required: required) do |value| 61 | case value 62 | when ::String 63 | begin 64 | [Utils.deep_freeze(::DateTime.rfc3339(value)), value.freeze] 65 | rescue ::Date::Error => e 66 | raise(AttributeError, "Illegal format for #{keys.first}: #{e.message}") 67 | end 68 | when ::DateTime 69 | [Utils.deep_freeze(value), value.rfc3339.freeze] 70 | when ::Time 71 | value = value.to_datetime 72 | [Utils.deep_freeze(value), value.rfc3339.freeze] 73 | else 74 | raise(AttributeError, "Illegal type for #{keys.first}: " \ 75 | "String, Time, or DateTime expected but #{value.class} found") 76 | end 77 | end 78 | end 79 | 80 | def content_type(keys, required: false) 81 | object(keys, required: required) do |value| 82 | case value 83 | when ::String 84 | raise(AttributeError, "The #{keys.first} field cannot be empty") if value.empty? 85 | [ContentType.new(value), value.freeze] 86 | when ContentType 87 | [value, value.to_s] 88 | else 89 | raise(AttributeError, "Illegal type for #{keys.first}: " \ 90 | "String, or ContentType expected but #{value.class} found") 91 | end 92 | end 93 | end 94 | 95 | def spec_version(keys, accept:) 96 | object(keys, required: true) do |value| 97 | case value 98 | when ::String 99 | raise(SpecVersionError, "Unrecognized specversion: #{value}") unless accept =~ value 100 | value.freeze 101 | [value, value] 102 | else 103 | raise(AttributeError, "Illegal type for #{keys.first}: " \ 104 | "String expected but #{value.class} found") 105 | end 106 | end 107 | end 108 | 109 | def data_object(keys, required: false) 110 | object(keys, required: required, allow_nil: true) do |value| 111 | Utils.deep_freeze(value) 112 | [value, value] 113 | end 114 | end 115 | 116 | UNDEFINED = ::Object.new.freeze 117 | 118 | private 119 | 120 | def object(keys, required: false, allow_nil: false) 121 | value = UNDEFINED 122 | keys.each do |key| 123 | key_present = @args.key?(key) 124 | val = @args.delete(key) 125 | value = val if (allow_nil && key_present) || (!allow_nil && !val.nil?) 126 | end 127 | if value == UNDEFINED 128 | raise(AttributeError, "The #{keys.first} field is required") if required 129 | return allow_nil ? UNDEFINED : nil 130 | end 131 | converted, raw = yield(value) 132 | @attributes[keys.first.freeze] = raw 133 | converted 134 | end 135 | 136 | def check_attribute_name(key, requires_lc_start) 137 | regex = requires_lc_start ? /^[a-z][a-z0-9]*$/ : /^[a-z0-9]+$/ 138 | unless regex.match?(key) 139 | raise(AttributeError, "Illegal key: #{key.inspect} must consist only of digits and lower-case letters") 140 | end 141 | end 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/cloud_events/event/opaque.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudEvents 4 | module Event 5 | ## 6 | # This object represents opaque event data that arrived in structured 7 | # content mode but was not in a recognized format. It may represent a 8 | # single event or a batch of events. 9 | # 10 | # The event data is retained in a form that can be reserialized (in a 11 | # structured content mode in the same format) but cannot otherwise be 12 | # inspected. 13 | # 14 | # This object is immutable, and Ractor-shareable on Ruby 3. 15 | # 16 | class Opaque 17 | ## 18 | # Create an opaque object wrapping the given content and a content type. 19 | # 20 | # @param content [String] The opaque serialized event data. 21 | # @param content_type [CloudEvents::ContentType,nil] The content type, 22 | # or `nil` if there is no content type. 23 | # @param batch [boolean] Whether this represents a batch. If set to `nil` 24 | # or not provided, the value will be inferred from the content type 25 | # if possible, or otherwise set to `nil` indicating not known. 26 | # 27 | def initialize(content, content_type, batch: nil) 28 | @content = content.freeze 29 | @content_type = content_type 30 | if batch.nil? && content_type&.media_type == "application" 31 | case content_type.subtype_base 32 | when "cloudevents" 33 | batch = false 34 | when "cloudevents-batch" 35 | batch = true 36 | end 37 | end 38 | @batch = batch 39 | freeze 40 | end 41 | 42 | ## 43 | # The opaque serialized event data 44 | # 45 | # @return [String] 46 | # 47 | attr_reader :content 48 | 49 | ## 50 | # The content type, or `nil` if there is no content type. 51 | # 52 | # @return [CloudEvents::ContentType,nil] 53 | # 54 | attr_reader :content_type 55 | 56 | ## 57 | # Whether this represents a batch, or `nil` if not known. 58 | # 59 | # @return [boolean,nil] 60 | # 61 | def batch? 62 | @batch 63 | end 64 | 65 | ## @private 66 | def ==(other) 67 | Opaque === other && 68 | @content == other.content && 69 | @content_type == other.content_type && 70 | @batch == other.batch? 71 | end 72 | alias eql? == 73 | 74 | ## @private 75 | def hash 76 | [@content, @content_type, @batch].hash 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/cloud_events/event/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudEvents 4 | module Event 5 | ## 6 | # A variety of helper methods. 7 | # @private 8 | # 9 | module Utils 10 | class << self 11 | def deep_freeze(obj) 12 | case obj 13 | when ::Hash 14 | obj.each do |key, val| 15 | deep_freeze(key) 16 | deep_freeze(val) 17 | end 18 | when ::Array 19 | obj.each do |val| 20 | deep_freeze(val) 21 | end 22 | else 23 | obj.instance_variables.each do |iv| 24 | deep_freeze(obj.instance_variable_get(iv)) 25 | end 26 | end 27 | obj.freeze 28 | end 29 | 30 | def deep_dup(obj) 31 | case obj 32 | when ::Hash 33 | obj.each_with_object({}) { |(key, val), hash| hash[deep_dup(key)] = deep_dup(val) } 34 | when ::Array 35 | obj.map { |val| deep_dup(val) } 36 | else 37 | obj.dup 38 | end 39 | end 40 | 41 | def keys_to_strings(hash) 42 | result = {} 43 | hash.each do |key, val| 44 | result[key.to_s] = val 45 | end 46 | result 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/cloud_events/event/v0.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "date" 4 | require "uri" 5 | 6 | require "cloud_events/event/field_interpreter" 7 | require "cloud_events/event/utils" 8 | 9 | module CloudEvents 10 | module Event 11 | ## 12 | # A CloudEvents V0 data type. 13 | # 14 | # This object represents a complete CloudEvent, including the event data 15 | # and context attributes. It supports the standard required and optional 16 | # attributes defined in CloudEvents V0.3, and arbitrary extension 17 | # attributes. All attribute values can be obtained (in their string form) 18 | # via the {Event::V0#[]} method. Additionally, standard attributes have 19 | # their own accessor methods that may return typed objects (such as 20 | # `DateTime` for the `time` attribute). 21 | # 22 | # This object is immutable, and Ractor-shareable on Ruby 3. The data and 23 | # attribute values can be retrieved but not modified. To obtain an event 24 | # with modifications, use the {#with} method to create a copy with the 25 | # desired changes. 26 | # 27 | # See https://github.com/cloudevents/spec/blob/v0.3/spec.md for 28 | # descriptions of the standard attributes. 29 | # 30 | class V0 31 | include Event 32 | 33 | ## 34 | # Create a new cloud event object with the given data and attributes. 35 | # 36 | # Event attributes may be presented as keyword arguments, or as a Hash 37 | # passed in via the special `:set_attributes` keyword argument (but not 38 | # both). The `:set_attributes` keyword argument is useful for passing in 39 | # attributes whose keys are strings rather than symbols, which some 40 | # versions of Ruby will not accept as keyword arguments. 41 | # 42 | # The following standard attributes are supported and exposed as 43 | # attribute methods on the object. 44 | # 45 | # * **:spec_version** (or **:specversion**) [`String`] - _required_ - 46 | # The CloudEvents spec version (i.e. the `specversion` field.) 47 | # * **:id** [`String`] - _required_ - The event `id` field. 48 | # * **:source** [`String`, `URI`] - _required_ - The event `source` 49 | # field. 50 | # * **:type** [`String`] - _required_ - The event `type` field. 51 | # * **:data** [`Object`] - _optional_ - The data associated with the 52 | # event (i.e. the `data` field). 53 | # * **:data_content_encoding** (or **:datacontentencoding**) 54 | # [`String`] - _optional_ - The content-encoding for the data (i.e. 55 | # the `datacontentencoding` field.) 56 | # * **:data_content_type** (or **:datacontenttype**) [`String`, 57 | # {ContentType}] - _optional_ - The content-type for the data, if 58 | # the data is a string (i.e. the event `datacontenttype` field.) 59 | # * **:schema_url** (or **:schemaurl**) [`String`, `URI`] - 60 | # _optional_ - The event `schemaurl` field. 61 | # * **:subject** [`String`] - _optional_ - The event `subject` field. 62 | # * **:time** [`String`, `DateTime`, `Time`] - _optional_ - The 63 | # event `time` field. 64 | # 65 | # Any additional attributes are assumed to be extension attributes. 66 | # They are not available as separate methods, but can be accessed via 67 | # the {Event::V1#[]} operator. 68 | # 69 | # Note that attribute objects passed in may get deep-frozen if they are 70 | # used in the final event object. This is particularly important for the 71 | # `:data` field, for example if you pass a structured hash. If this is an 72 | # issue, make a deep copy of objects before passing to this constructor. 73 | # 74 | # @param set_attributes [Hash] The data and attributes, as a hash. 75 | # (Also available using the deprecated keyword `attributes`.) 76 | # @param args [keywords] The data and attributes, as keyword arguments. 77 | # 78 | def initialize(set_attributes: nil, attributes: nil, **args) 79 | interpreter = FieldInterpreter.new(set_attributes || attributes || args) 80 | @spec_version = interpreter.spec_version(["specversion", "spec_version"], accept: /^0\.3$/) 81 | @id = interpreter.string(["id"], required: true) 82 | @source = interpreter.uri(["source"], required: true) 83 | @type = interpreter.string(["type"], required: true) 84 | @data = interpreter.data_object(["data"]) 85 | @data = nil if @data == FieldInterpreter::UNDEFINED 86 | @data_content_encoding = interpreter.string(["datacontentencoding", "data_content_encoding"]) 87 | @data_content_type = interpreter.content_type(["datacontenttype", "data_content_type"]) 88 | @schema_url = interpreter.uri(["schemaurl", "schema_url"]) 89 | @subject = interpreter.string(["subject"]) 90 | @time = interpreter.rfc3339_date_time(["time"]) 91 | @attributes = interpreter.finish_attributes(requires_lc_start: true) 92 | freeze 93 | end 94 | 95 | ## 96 | # Create and return a copy of this event with the given changes. See 97 | # the constructor for the parameters that can be passed. In general, 98 | # you can pass a new value for any attribute, or pass `nil` to remove 99 | # an optional attribute. 100 | # 101 | # @param changes [keywords] See {#initialize} for a list of arguments. 102 | # @return [FunctionFramework::CloudEvents::Event] 103 | # 104 | def with(**changes) 105 | attributes = @attributes.merge(changes) 106 | V0.new(set_attributes: attributes) 107 | end 108 | 109 | ## 110 | # Return the value of the given named attribute. Both standard and 111 | # extension attributes are supported. 112 | # 113 | # Attribute names must be given as defined in the standard CloudEvents 114 | # specification. For example `specversion` rather than `spec_version`. 115 | # 116 | # Results are given in their "raw" form, generally a string. This may 117 | # be different from the Ruby object returned from corresponding 118 | # attribute methods. For example: 119 | # 120 | # event["time"] # => String rfc3339 representation 121 | # event.time # => DateTime object 122 | # 123 | # Results are also always frozen and cannot be modified in place. 124 | # 125 | # @param key [String,Symbol] The attribute name. 126 | # @return [String,nil] 127 | # 128 | def [](key) 129 | @attributes[key.to_s] 130 | end 131 | 132 | ## 133 | # Return a hash representation of this event. The returned hash is an 134 | # unfrozen deep copy. Modifications do not affect the original event. 135 | # 136 | # @return [Hash] 137 | # 138 | def to_h 139 | Utils.deep_dup(@attributes) 140 | end 141 | 142 | ## 143 | # The `id` field. Required. 144 | # 145 | # @return [String] 146 | # 147 | attr_reader :id 148 | 149 | ## 150 | # The `source` field as a `URI` object. Required. 151 | # 152 | # @return [URI] 153 | # 154 | attr_reader :source 155 | 156 | ## 157 | # The `type` field. Required. 158 | # 159 | # @return [String] 160 | # 161 | attr_reader :type 162 | 163 | ## 164 | # The `specversion` field. Required. 165 | # 166 | # @return [String] 167 | # 168 | attr_reader :spec_version 169 | alias specversion spec_version 170 | 171 | ## 172 | # The event-specific data, or `nil` if there is no data. 173 | # 174 | # Data may be one of the following types: 175 | # * Binary data, represented by a `String` using the `ASCII-8BIT` 176 | # encoding. 177 | # * A string in some other encoding such as `UTF-8` or `US-ASCII`. 178 | # * Any JSON data type, such as a Boolean, Integer, Array, Hash, or 179 | # `nil`. 180 | # 181 | # @return [Object] 182 | # 183 | attr_reader :data 184 | 185 | ## 186 | # The optional `datacontentencoding` field as a `String` object, or 187 | # `nil` if the field is absent. 188 | # 189 | # @return [String,nil] 190 | # 191 | attr_reader :data_content_encoding 192 | alias datacontentencoding data_content_encoding 193 | 194 | ## 195 | # The optional `datacontenttype` field as a {CloudEvents::ContentType} 196 | # object, or `nil` if the field is absent. 197 | # 198 | # @return [CloudEvents::ContentType,nil] 199 | # 200 | attr_reader :data_content_type 201 | alias datacontenttype data_content_type 202 | 203 | ## 204 | # The optional `schemaurl` field as a `URI` object, or `nil` if the 205 | # field is absent. 206 | # 207 | # @return [URI,nil] 208 | # 209 | attr_reader :schema_url 210 | alias schemaurl schema_url 211 | 212 | ## 213 | # The optional `subject` field, or `nil` if the field is absent. 214 | # 215 | # @return [String,nil] 216 | # 217 | attr_reader :subject 218 | 219 | ## 220 | # The optional `time` field as a `DateTime` object, or `nil` if the 221 | # field is absent. 222 | # 223 | # @return [DateTime,nil] 224 | # 225 | attr_reader :time 226 | 227 | ## @private 228 | def ==(other) 229 | other.is_a?(V0) && @attributes == other.instance_variable_get(:@attributes) 230 | end 231 | alias eql? == 232 | 233 | ## @private 234 | def hash 235 | @attributes.hash 236 | end 237 | end 238 | end 239 | end 240 | -------------------------------------------------------------------------------- /lib/cloud_events/event/v1.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "date" 4 | require "uri" 5 | 6 | require "cloud_events/event/field_interpreter" 7 | require "cloud_events/event/utils" 8 | 9 | module CloudEvents 10 | module Event 11 | ## 12 | # A CloudEvents V1 data type. 13 | # 14 | # This object represents a complete CloudEvent, including the event data 15 | # and context attributes. It supports the standard required and optional 16 | # attributes defined in CloudEvents V1.0, and arbitrary extension 17 | # attributes. 18 | # 19 | # Values for most attributes can be obtained in their encoded string form 20 | # via the {Event::V1#[]} method. Additionally, standard attributes have 21 | # their own accessor methods that may return decoded Ruby objects (such as 22 | # a `DateTime` object for the `time` attribute). 23 | # 24 | # The `data` attribute is treated specially because it is subject to 25 | # arbitrary encoding governed by the `datacontenttype` attribute. Data is 26 | # expressed in two related fields: {Event::V1#data} and 27 | # {Event::V1#data_encoded}. The former, `data`, _may_ be an arbitrary Ruby 28 | # object representing the decoded form of the data (for example, a Hash for 29 | # most JSON-formatted data.) The latter, `data_encoded`, _must_, if 30 | # present, be a Ruby String object representing the encoded string or 31 | # byte array form of the data. 32 | # 33 | # When the CloudEvents Ruby SDK encodes an event for transmission, it will 34 | # use the `data_encoded` field if present. Otherwise, it will attempt to 35 | # encode the `data` field using any available encoder that recognizes the 36 | # content-type. Currently, text and JSON types are supported. If the type 37 | # is not supported, event encoding may fail. It is thus recommended that 38 | # applications provide a `data_encoded` string, if the `data` object is 39 | # nontrivially encoded. 40 | # 41 | # This object is immutable, and Ractor-shareable on Ruby 3. The data and 42 | # attribute values can be retrieved but not modified. To obtain an event 43 | # with modifications, use the {#with} method to create a copy with the 44 | # desired changes. 45 | # 46 | # See https://github.com/cloudevents/spec/blob/v1.0/spec.md for 47 | # descriptions of the standard attributes. 48 | # 49 | class V1 50 | include Event 51 | 52 | ## 53 | # Create a new cloud event object with the given data and attributes. 54 | # 55 | # ### Specifying event attributes 56 | # 57 | # Event attributes may be presented as keyword arguments, or as a Hash 58 | # passed in via the special `:set_attributes` keyword argument (but not 59 | # both). The `:set_attributes` keyword argument is useful for passing in 60 | # attributes whose keys are strings rather than symbols, which some 61 | # versions of Ruby will not accept as keyword arguments. 62 | # 63 | # The following standard attributes are supported and exposed as 64 | # attribute methods on the object. 65 | # 66 | # * **:spec_version** (or **:specversion**) [`String`] - _required_ - 67 | # The CloudEvents spec version (i.e. the `specversion` field.) 68 | # * **:id** [`String`] - _required_ - The event `id` field. 69 | # * **:source** [`String`, `URI`] - _required_ - The event `source` 70 | # field. 71 | # * **:type** [`String`] - _required_ - The event `type` field. 72 | # * **:data** [`Object`] - _optional_ - The "decoded" Ruby object form 73 | # of the event `data` field, if known. (e.g. a Hash representing a 74 | # JSON document) 75 | # * **:data_encoded** [`String`] - _optional_ - The "encoded" string 76 | # form of the event `data` field, if known. This should be set along 77 | # with the `data_content_type`. 78 | # * **:data_content_type** (or **:datacontenttype**) [`String`, 79 | # {ContentType}] - _optional_ - The content-type for the encoded data 80 | # (i.e. the event `datacontenttype` field.) 81 | # * **:data_schema** (or **:dataschema**) [`String`, `URI`] - 82 | # _optional_ - The event `dataschema` field. 83 | # * **:subject** [`String`] - _optional_ - The event `subject` field. 84 | # * **:time** [`String`, `DateTime`, `Time`] - _optional_ - The 85 | # event `time` field. 86 | # 87 | # Any additional attributes are assumed to be extension attributes. 88 | # They are not available as separate methods, but can be accessed via 89 | # the {Event::V1#[]} operator. 90 | # 91 | # Note that attribute objects passed in may get deep-frozen if they are 92 | # used in the final event object. This is particularly important for the 93 | # `:data` field, for example if you pass a structured hash. If this is an 94 | # issue, make a deep copy of objects before passing to this constructor. 95 | # 96 | # ### Specifying payload data 97 | # 98 | # Typically you should provide _both_ the `:data` and `:data_encoded` 99 | # fields, the former representing the decoded (Ruby object) form of the 100 | # data, and the second providing a hint to formatters and protocol 101 | # bindings for how to seralize the data. In this case, the {#data} and 102 | # {#data_encoded} methods will return the corresponding values, and 103 | # {#data_decoded?} will return true to indicate that {#data} represents 104 | # the decoded form. 105 | # 106 | # If you provide _only_ the `:data` field, omitting `:data_encoded`, then 107 | # the value is expected to represent the decoded (Ruby object) form of 108 | # the data. The {#data} method will return this decoded value, and 109 | # {#data_decoded?} will return true. The {#data_encoded} method will 110 | # return nil. 111 | # When serializing such an event, it will be up to the formatter or 112 | # protocol binding to encode the data. This means serialization _could_ 113 | # fail if the formatter does not understand the data's content type. 114 | # Omitting `:data_encoded` is common if the content type is JSON related 115 | # (e.g. `application/json`) and the event is being encoded in JSON 116 | # structured format, because the data encoding is trivial. This form can 117 | # also be used when the content type is `text/*`, for which encoding is 118 | # also trivial. 119 | # 120 | # If you provide _only_ the `:data_encoded` field, omitting `:data`, then 121 | # the value is expected to represent the encoded (string) form of the 122 | # data. The {#data_encoded} method will return this value. Additionally, 123 | # the {#data} method will return the same _encoded_ value, and 124 | # {#data_decoded?} will return false. 125 | # Event objects of this form may be returned from a protocol binding when 126 | # it decodes an event with a `datacontenttype` that it does not know how 127 | # to interpret. Applications should query {#data_decoded?} to determine 128 | # whether the {#data} method returns encoded or decoded data. 129 | # 130 | # If you provide _neither_ `:data` nor `:data_encoded`, the event will 131 | # have no payload data. Both {#data} and {#data_encoded} will return nil, 132 | # and {#data_decoded?} will return false. (Additionally, {#data?} will 133 | # return false to signal the absence of any data.) 134 | # 135 | # @param set_attributes [Hash] The data and attributes, as a hash. 136 | # (Also available using the deprecated keyword `attributes`.) 137 | # @param args [keywords] The data and attributes, as keyword arguments. 138 | # 139 | def initialize(set_attributes: nil, attributes: nil, **args) 140 | interpreter = FieldInterpreter.new(set_attributes || attributes || args) 141 | @spec_version = interpreter.spec_version(["specversion", "spec_version"], accept: /^1(\.|$)/) 142 | @id = interpreter.string(["id"], required: true) 143 | @source = interpreter.uri(["source"], required: true) 144 | @type = interpreter.string(["type"], required: true) 145 | @data_encoded = interpreter.string(["data_encoded"], allow_empty: true) 146 | @data = interpreter.data_object(["data"]) 147 | if @data == FieldInterpreter::UNDEFINED 148 | @data = @data_encoded 149 | @data_decoded = false 150 | else 151 | @data_decoded = true 152 | end 153 | @data_content_type = interpreter.content_type(["datacontenttype", "data_content_type"]) 154 | @data_schema = interpreter.uri(["dataschema", "data_schema"]) 155 | @subject = interpreter.string(["subject"]) 156 | @time = interpreter.rfc3339_date_time(["time"]) 157 | @attributes = interpreter.finish_attributes 158 | freeze 159 | end 160 | 161 | ## 162 | # Create and return a copy of this event with the given changes. See 163 | # the constructor for the parameters that can be passed. In general, 164 | # you can pass a new value for any attribute, or pass `nil` to remove 165 | # an optional attribute. 166 | # 167 | # @param changes [keywords] See {#initialize} for a list of arguments. 168 | # @return [FunctionFramework::CloudEvents::Event] 169 | # 170 | def with(**changes) 171 | changes = Utils.keys_to_strings(changes) 172 | attributes = @attributes.dup 173 | if changes.key?("data") || changes.key?("data_encoded") 174 | attributes.delete("data") 175 | attributes.delete("data_encoded") 176 | end 177 | attributes.merge!(changes) 178 | V1.new(set_attributes: attributes) 179 | end 180 | 181 | ## 182 | # Return the value of the given named attribute. Both standard and 183 | # extension attributes are supported. 184 | # 185 | # Attribute names must be given as defined in the standard CloudEvents 186 | # specification. For example `specversion` rather than `spec_version`. 187 | # 188 | # Results are given in their "raw" form, generally a string. This may 189 | # be different from the Ruby object returned from corresponding 190 | # attribute methods. For example: 191 | # 192 | # event["time"] # => String rfc3339 representation 193 | # event.time # => DateTime object 194 | # 195 | # Results are also always frozen and cannot be modified in place. 196 | # 197 | # @param key [String,Symbol] The attribute name. 198 | # @return [String,nil] 199 | # 200 | def [](key) 201 | @attributes[key.to_s] 202 | end 203 | 204 | ## 205 | # Return a hash representation of this event. The returned hash is an 206 | # unfrozen deep copy. Modifications do not affect the original event. 207 | # 208 | # @return [Hash] 209 | # 210 | def to_h 211 | Utils.deep_dup(@attributes) 212 | end 213 | 214 | ## 215 | # The `id` field. Required. 216 | # 217 | # @return [String] 218 | # 219 | attr_reader :id 220 | 221 | ## 222 | # The `source` field as a `URI` object. Required. 223 | # 224 | # @return [URI] 225 | # 226 | attr_reader :source 227 | 228 | ## 229 | # The `type` field. Required. 230 | # 231 | # @return [String] 232 | # 233 | attr_reader :type 234 | 235 | ## 236 | # The `specversion` field. Required. 237 | # 238 | # @return [String] 239 | # 240 | attr_reader :spec_version 241 | alias specversion spec_version 242 | 243 | ## 244 | # The event `data` field, or `nil` if there is no data. 245 | # 246 | # This may return the data as an encoded string _or_ as a decoded Ruby 247 | # object. The {#data_decoded?} method specifies whether the `data` value 248 | # is decoded or encoded. 249 | # 250 | # In most cases, {#data} returns a decoded value, unless the event was 251 | # received from a source that could not decode the content. For example, 252 | # most protocol bindings understand how to decode JSON, so an event 253 | # received with a {#data_content_type} of `application/json` will usually 254 | # return a decoded object (usually a Hash) from {#data}. 255 | # 256 | # See also {#data_encoded} and {#data_decoded?}. 257 | # 258 | # @return [Object] if containing decoded data 259 | # @return [String] if containing encoded data 260 | # @return [nil] if there is no data 261 | # 262 | attr_reader :data 263 | 264 | ## 265 | # The encoded string representation of the data, i.e. its raw form used 266 | # when encoding an event for transmission. This may be `nil` if there is 267 | # no data, or if the encoded form is not known. 268 | # 269 | # See also {#data}. 270 | # 271 | # @return [String,nil] 272 | # 273 | attr_reader :data_encoded 274 | 275 | ## 276 | # Indicates whether the {#data} field returns decoded data. 277 | # 278 | # @return [true] if {#data} returns a decoded Ruby object 279 | # @return [false] if {#data} returns an encoded string or if the event 280 | # has no data. 281 | # 282 | def data_decoded? 283 | @data_decoded 284 | end 285 | 286 | ## 287 | # Indicates whether the data field is present. If there is no data, 288 | # {#data} will return `nil`, and {#data_decoded?} will return false. 289 | # 290 | # Generally, if there is no data, the {#data_content_type} field should 291 | # also be absent, but this is not enforced. 292 | # 293 | # @return [boolean] 294 | # 295 | def data? 296 | !@data.nil? || @data_decoded 297 | end 298 | 299 | ## 300 | # The optional `datacontenttype` field as a {CloudEvents::ContentType} 301 | # object, or `nil` if the field is absent. 302 | # 303 | # @return [CloudEvents::ContentType,nil] 304 | # 305 | attr_reader :data_content_type 306 | alias datacontenttype data_content_type 307 | 308 | ## 309 | # The optional `dataschema` field as a `URI` object, or `nil` if the 310 | # field is absent. 311 | # 312 | # @return [URI,nil] 313 | # 314 | attr_reader :data_schema 315 | alias dataschema data_schema 316 | 317 | ## 318 | # The optional `subject` field, or `nil` if the field is absent. 319 | # 320 | # @return [String,nil] 321 | # 322 | attr_reader :subject 323 | 324 | ## 325 | # The optional `time` field as a `DateTime` object, or `nil` if the 326 | # field is absent. 327 | # 328 | # @return [DateTime,nil] 329 | # 330 | attr_reader :time 331 | 332 | ## @private 333 | def ==(other) 334 | other.is_a?(V1) && @attributes == other.instance_variable_get(:@attributes) 335 | end 336 | alias eql? == 337 | 338 | ## @private 339 | def hash 340 | @attributes.hash 341 | end 342 | end 343 | end 344 | end 345 | -------------------------------------------------------------------------------- /lib/cloud_events/format.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module CloudEvents 6 | ## 7 | # This module documents the method signatures that may be implemented by 8 | # formatters. 9 | # 10 | # A formatter is an object that implements "structured" event encoding and 11 | # decoding strategies for a particular format (such as JSON). In general, 12 | # this includes four operations: 13 | # 14 | # * Decoding an entire event or batch of events from a input source. 15 | # This is implemented by the {Format#decode_event} method. 16 | # * Encoding an entire event or batch of events to an output sink. 17 | # This is implemented by the {Format#encode_event} method. 18 | # * Decoding an event payload (i.e. the `data` attribute) Ruby object from a 19 | # serialized representation. 20 | # This is implemented by the {Format#decode_data} method. 21 | # * Encoding an event payload (i.e. the `data` attribute) Ruby object to a 22 | # serialized representation. 23 | # This is implemented by the {Format#encode_data} method. 24 | # 25 | # Each method takes a set of keyword arguments, and returns either a `Hash` 26 | # or `nil`. A Hash indicates that the formatter understands the request and 27 | # is returning its response. A return value of `nil` means the formatter does 28 | # not understand the request and is declining to perform the operation. In 29 | # such a case, it is possible that a different formatter should handle it. 30 | # 31 | # Both the keyword arguments recognized and the returned hash members may 32 | # vary from formatter to formatter; similarly, the keyword arguments provided 33 | # and the returned hash members recognized may also vary for different 34 | # callers. This interface will define a set of common argument and result key 35 | # names, but both callers and formatters must gracefully handle the case of 36 | # missing or extra information. For example, if a formatter expects a certain 37 | # argument but does not receive it, it can assume the caller does not have 38 | # the required information, and it may respond by returning `nil` to decline 39 | # the request. Similarly, if a caller expects a response key but does not 40 | # receive it, it can assume the formatter does not provide it, and it may 41 | # respond by trying a different formatter. 42 | # 43 | # Additionally, any particular formatter need not implement all methods. For 44 | # example, an event formatter would generally implement {Format#decode_event} 45 | # and {Format#encode_event}, but might not implement {Format#decode_data} or 46 | # {Format#encode_data}. 47 | # 48 | # Finally, this module itself is present primarily for documentation, and 49 | # need not be directly included by formatter implementations. 50 | # 51 | module Format 52 | ## 53 | # Decode an event or batch from the given serialized input. This is 54 | # typically called by a protocol binding to deserialize event data from an 55 | # input stream. 56 | # 57 | # Common arguments include: 58 | # 59 | # * `:content` (String) Serialized content to decode. For example, it could 60 | # be from an HTTP request body. 61 | # * `:content_type` ({CloudEvents::ContentType}) The content type. For 62 | # example, it could be from the `Content-Type` header of an HTTP request. 63 | # 64 | # The formatter must first determine whether it is able to interpret the 65 | # given input. Typically, this is done by inspecting the `content_type`. 66 | # If the formatter determines that it is unable to interpret the input, it 67 | # should return `nil`. Otherwise, if the formatter determines it can decode 68 | # the input, it should return a `Hash`. Common hash keys include: 69 | # 70 | # * `:event` ({CloudEvents::Event}) A single event decoded from the input. 71 | # * `:event_batch` (Array of {CloudEvents::Event}) A batch of events 72 | # decoded from the input. 73 | # 74 | # The formatter may also raise a {CloudEvents::CloudEventsError} subclass 75 | # if it understood the request but determines that the input source is 76 | # malformed. 77 | # 78 | # @param _kwargs [keywords] Arguments 79 | # @return [Hash] if accepting the request and returning a result 80 | # @return [nil] if declining the request. 81 | # 82 | def decode_event(**_kwargs) 83 | nil 84 | end 85 | 86 | ## 87 | # Encode an event or batch to a string. This is typically called by a 88 | # protocol binding to serialize event data to an output stream. 89 | # 90 | # Common arguments include: 91 | # 92 | # * `:event` ({CloudEvents::Event}) A single event to encode. 93 | # * `:event_batch` (Array of {CloudEvents::Event}) A batch of events to 94 | # encode. 95 | # 96 | # The formatter must first determine whether it is able to interpret the 97 | # given input. Typically, most formatters should be able to handle any 98 | # event or event batch, but a specialized formatter that can handle only 99 | # certain kinds of events may return `nil` to decline unwanted inputs. 100 | # Otherwise, if the formatter determines it can encode the input, it should 101 | # return a `Hash`. common hash keys include: 102 | # 103 | # * `:content` (String) The serialized form of the event. This might, for 104 | # example, be written to an HTTP request body. Care should be taken to 105 | # set the string's encoding properly. In particular, to output binary 106 | # data, the encoding should probably be set to `ASCII_8BIT`. 107 | # * `:content_type` ({CloudEvents::ContentType}) The content type for the 108 | # output. This might, for example, be written to the `Content-Type` 109 | # header of an HTTP request. 110 | # 111 | # The formatter may also raise a {CloudEvents::CloudEventsError} subclass 112 | # if it understood the request but determines that the input source is 113 | # malformed. 114 | # 115 | # @param _kwargs [keywords] Arguments 116 | # @return [Hash] if accepting the request and returning a result 117 | # @return [nil] if declining the request. 118 | # 119 | def encode_event(**_kwargs) 120 | nil 121 | end 122 | 123 | ## 124 | # Decode an event data object from string format. This is typically called 125 | # by a protocol binding to deserialize the payload (i.e. `data` attribute) 126 | # of an event as part of "binary content mode" decoding. 127 | # 128 | # Common arguments include: 129 | # 130 | # * `:spec_version` (String) The `specversion` of the event. 131 | # * `:content` (String) Serialized payload to decode. For example, it could 132 | # be from an HTTP request body. 133 | # * `:content_type` ({CloudEvents::ContentType}) The content type. For 134 | # example, it could be from the `Content-Type` header of an HTTP request. 135 | # 136 | # The formatter must first determine whether it is able to interpret the 137 | # given input. Typically, this is done by inspecting the `content_type`. 138 | # If the formatter determines that it is unable to interpret the input, it 139 | # should return `nil`. Otherwise, if the formatter determines it can decode 140 | # the input, it should return a `Hash`. Common hash keys include: 141 | # 142 | # * `:data` (Object) The payload object to be set as the `data` attribute 143 | # in a {CloudEvents::Event} object. 144 | # * `:content_type` ({CloudEvents::ContentType}) The content type to be set 145 | # as the `datacontenttype` attribute in a {CloudEvents::Event} object. 146 | # In many cases, this may simply be copied from the `:content_type` 147 | # argument, but a formatter could modify it to provide corrections or 148 | # additional information. 149 | # 150 | # The formatter may also raise a {CloudEvents::CloudEventsError} subclass 151 | # if it understood the request but determines that the input source is 152 | # malformed. 153 | # 154 | # @param _kwargs [keywords] Arguments 155 | # @return [Hash] if accepting the request and returning a result 156 | # @return [nil] if declining the request. 157 | # 158 | def decode_data(**_kwargs) 159 | nil 160 | end 161 | 162 | ## 163 | # Encode an event data object to string format. This is typically called by 164 | # a protocol binding to serialize the payload (i.e. `data` attribute and 165 | # corresponding `datacontenttype` attribute) of an event as part of "binary 166 | # content mode" encoding. 167 | # 168 | # Common arguments include: 169 | # 170 | # * `:spec_version` (String) The `specversion` of the event. 171 | # * `:data` (Object) The payload object from an event's `data` attribute. 172 | # * `:content_type` ({CloudEvents::ContentType}) The content type from an 173 | # event's `datacontenttype` attribute. 174 | # 175 | # The formatter must first determine whether it is able to interpret the 176 | # given input. Typically, this is done by inspecting the `content_type`. 177 | # If the formatter determines that it is unable to interpret the input, it 178 | # should return `nil`. Otherwise, if the formatter determines it can decode 179 | # the input, it should return a `Hash`. Common hash keys include: 180 | # 181 | # * `:content` (String) The serialized form of the data. This might, for 182 | # example, be written to an HTTP request body. Care should be taken to 183 | # set the string's encoding properly. In particular, to output binary 184 | # data, the encoding should generally be set to `ASCII_8BIT`. 185 | # * `:content_type` ({CloudEvents::ContentType}) The content type for the 186 | # output. This might, for example, be written to the `Content-Type` 187 | # header of an HTTP request. 188 | # 189 | # The formatter may also raise a {CloudEvents::CloudEventsError} subclass 190 | # if it understood the request but determines that the input source is 191 | # malformed. 192 | # 193 | # @param _kwargs [keywords] Arguments 194 | # @return [Hash] if accepting the request and returning a result 195 | # @return [nil] if declining the request. 196 | # 197 | def encode_data(**_kwargs) 198 | nil 199 | end 200 | 201 | ## 202 | # A convenience formatter that checks multiple formats for one capable of 203 | # handling the given input. 204 | # 205 | class Multi 206 | ## 207 | # Create a multi format. 208 | # 209 | # @param formats [Array] An array of formats to check in order 210 | # @param result_checker [Proc] An optional block that determines whether 211 | # a result provided by a format is acceptable. The block takes the 212 | # format's result as an argument, and returns either the result to 213 | # indicate acceptability, or `nil` to indicate not. 214 | # 215 | def initialize(formats = [], &result_checker) 216 | @formats = formats 217 | @result_checker = result_checker 218 | end 219 | 220 | ## 221 | # The formats to check, in order. 222 | # 223 | # @return [Array] 224 | # 225 | attr_reader :formats 226 | 227 | ## 228 | # Implements {Format#decode_event} 229 | # 230 | def decode_event(**kwargs) 231 | @formats.each do |elem| 232 | result = elem.decode_event(**kwargs) 233 | result = @result_checker.call(result) if @result_checker 234 | return result if result 235 | end 236 | nil 237 | end 238 | 239 | ## 240 | # Implements {Format#encode_event} 241 | # 242 | def encode_event(**kwargs) 243 | @formats.each do |elem| 244 | result = elem.encode_event(**kwargs) 245 | result = @result_checker.call(result) if @result_checker 246 | return result if result 247 | end 248 | nil 249 | end 250 | 251 | ## 252 | # Implements {Format#decode_data} 253 | # 254 | def decode_data(**kwargs) 255 | @formats.each do |elem| 256 | result = elem.decode_data(**kwargs) 257 | result = @result_checker.call(result) if @result_checker 258 | return result if result 259 | end 260 | nil 261 | end 262 | 263 | ## 264 | # Implements {Format#encode_data} 265 | # 266 | def encode_data(**kwargs) 267 | @formats.each do |elem| 268 | result = elem.encode_data(**kwargs) 269 | result = @result_checker.call(result) if @result_checker 270 | return result if result 271 | end 272 | nil 273 | end 274 | end 275 | end 276 | end 277 | -------------------------------------------------------------------------------- /lib/cloud_events/http_binding.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudEvents 4 | ## 5 | # HTTP binding for CloudEvents. 6 | # 7 | # This class implements HTTP binding, including unmarshalling of events from 8 | # Rack environment data, and marshalling of events to Rack environment data. 9 | # It supports binary (i.e. header-based) HTTP content, as well as structured 10 | # (body-based) content that can delegate to formatters such as JSON. 11 | # 12 | # Supports the CloudEvents 0.3 and CloudEvents 1.0 variants of this format. 13 | # See https://github.com/cloudevents/spec/blob/v0.3/http-transport-binding.md 14 | # and https://github.com/cloudevents/spec/blob/v1.0/http-protocol-binding.md. 15 | # 16 | class HttpBinding 17 | ## 18 | # The name of the JSON decoder/encoder 19 | # @return [String] 20 | # 21 | JSON_FORMAT = "json" 22 | 23 | # @private 24 | ILLEGAL_METHODS = ["GET", "HEAD"].freeze 25 | 26 | ## 27 | # Returns a default HTTP binding, including support for JSON format. 28 | # 29 | # @return [HttpBinding] 30 | # 31 | def self.default 32 | @default ||= begin 33 | http_binding = new 34 | http_binding.register_formatter(JsonFormat.new, encoder_name: JSON_FORMAT) 35 | http_binding.default_encoder_name = JSON_FORMAT 36 | http_binding 37 | end 38 | end 39 | 40 | ## 41 | # Create an empty HTTP binding. 42 | # 43 | def initialize 44 | @event_decoders = Format::Multi.new do |result| 45 | result&.key?(:event) || result&.key?(:event_batch) ? result : nil 46 | end 47 | @event_encoders = {} 48 | @data_decoders = Format::Multi.new do |result| 49 | result&.key?(:data) && result.key?(:content_type) ? result : nil 50 | end 51 | @data_encoders = Format::Multi.new do |result| 52 | result&.key?(:content) && result.key?(:content_type) ? result : nil 53 | end 54 | text_format = TextFormat.new 55 | @data_decoders.formats.replace([text_format, DefaultDataFormat]) 56 | @data_encoders.formats.replace([text_format, DefaultDataFormat]) 57 | 58 | @default_encoder_name = nil 59 | end 60 | 61 | ## 62 | # Register a formatter for all operations it supports, based on which 63 | # methods are implemented by the formatter object. See 64 | # {CloudEvents::Format} for a list of possible methods. 65 | # 66 | # @param formatter [Object] The formatter 67 | # @param encoder_name [String] The encoder name under which this formatter 68 | # will register its encode operations. Optional. If not specified, any 69 | # event encoder will _not_ be registered. 70 | # @param deprecated_name [String] This positional argument is deprecated 71 | # and will be removed in version 1.0. Use encoder_name instead. 72 | # @return [self] 73 | # 74 | def register_formatter(formatter, deprecated_name = nil, encoder_name: nil) 75 | encoder_name ||= deprecated_name 76 | encoder_name = encoder_name.to_s.strip.downcase if encoder_name 77 | decode_event = formatter.respond_to?(:decode_event) 78 | encode_event = encoder_name if formatter.respond_to?(:encode_event) 79 | decode_data = formatter.respond_to?(:decode_data) 80 | encode_data = formatter.respond_to?(:encode_data) 81 | register_formatter_methods(formatter, 82 | decode_event: decode_event, 83 | encode_event: encode_event, 84 | decode_data: decode_data, 85 | encode_data: encode_data) 86 | self 87 | end 88 | 89 | ## 90 | # Registers the given formatter for the given operations. Some arguments 91 | # are activated by passing `true`, whereas those that rely on a format name 92 | # are activated by passing in a name string. 93 | # 94 | # @param formatter [Object] The formatter 95 | # @param decode_event [boolean] If true, register the formatter's 96 | # {CloudEvents::Format#decode_event} method. 97 | # @param encode_event [String] If set to a string, use the formatter's 98 | # {CloudEvents::Format#encode_event} method when that name is requested. 99 | # @param decode_data [boolean] If true, register the formatter's 100 | # {CloudEvents::Format#decode_data} method. 101 | # @param encode_data [boolean] If true, register the formatter's 102 | # {CloudEvents::Format#encode_data} method. 103 | # @return [self] 104 | # 105 | def register_formatter_methods(formatter, 106 | decode_event: false, 107 | encode_event: nil, 108 | decode_data: false, 109 | encode_data: false) 110 | @event_decoders.formats.unshift(formatter) if decode_event 111 | if encode_event 112 | encoders = @event_encoders[encode_event] ||= Format::Multi.new do |result| 113 | result&.key?(:content) && result.key?(:content_type) ? result : nil 114 | end 115 | encoders.formats.unshift(formatter) 116 | end 117 | @data_decoders.formats.unshift(formatter) if decode_data 118 | @data_encoders.formats.unshift(formatter) if encode_data 119 | self 120 | end 121 | 122 | ## 123 | # The name of the encoder to use if none is specified 124 | # 125 | # @return [String,nil] 126 | # 127 | attr_accessor :default_encoder_name 128 | 129 | ## 130 | # Analyze a Rack environment hash and determine whether it is _probably_ 131 | # a CloudEvent. This is done by examining headers only, and does not read 132 | # or parse the request body. The result is a best guess: false negatives or 133 | # false positives are possible for edge cases, but the logic should 134 | # generally detect canonically-formatted events. 135 | # 136 | # @param env [Hash] The Rack environment. 137 | # @return [boolean] Whether the request is likely a CloudEvent. 138 | # 139 | def probable_event?(env) 140 | return false if ILLEGAL_METHODS.include?(env["REQUEST_METHOD"]) 141 | return true if env["HTTP_CE_SPECVERSION"] 142 | content_type = ContentType.new(env["CONTENT_TYPE"].to_s) 143 | content_type.media_type == "application" && 144 | ["cloudevents", "cloudevents-batch"].include?(content_type.subtype_base) 145 | end 146 | 147 | ## 148 | # Decode an event from the given Rack environment hash. Following the 149 | # CloudEvents spec, this chooses a handler based on the Content-Type of 150 | # the request. 151 | # 152 | # Note that this method will read the body (i.e. `rack.input`) stream. 153 | # If you need to access the body after calling this method, you will need 154 | # to rewind the stream. To determine whether the request is a CloudEvent 155 | # without reading the body, use {#probable_event?}. 156 | # 157 | # @param env [Hash] The Rack environment. 158 | # @param allow_opaque [boolean] If true, returns opaque event objects if 159 | # the input is not in a recognized format. If false, raises 160 | # {CloudEvents::UnsupportedFormatError} in that case. Default is false. 161 | # @param format_args [keywords] Extra args to pass to the formatter. 162 | # @return [CloudEvents::Event] if the request includes a single structured 163 | # or binary event. 164 | # @return [Array] if the request includes a batch of 165 | # structured events. 166 | # @raise [CloudEvents::CloudEventsError] if an event could not be decoded 167 | # from the request. 168 | # 169 | def decode_event(env, allow_opaque: false, **format_args) 170 | request_method = env["REQUEST_METHOD"] 171 | raise(NotCloudEventError, "Request method is #{request_method}") if ILLEGAL_METHODS.include?(request_method) 172 | content_type_string = env["CONTENT_TYPE"] 173 | content_type = ContentType.new(content_type_string) if content_type_string 174 | content = read_with_charset(env["rack.input"], content_type&.charset) 175 | result = decode_binary_content(content, content_type, env, false, **format_args) || 176 | decode_structured_content(content, content_type, allow_opaque, **format_args) 177 | if result.nil? 178 | content_type_string = content_type_string ? content_type_string.inspect : "not present" 179 | raise(NotCloudEventError, "Content-Type is #{content_type_string}, and CE-SpecVersion is not present") 180 | end 181 | result 182 | end 183 | 184 | ## 185 | # Encode an event or batch of events into HTTP headers and body. 186 | # 187 | # You may provide an event, an array of events, or an opaque event. You may 188 | # also specify what content mode and format to use. 189 | # 190 | # The result is a two-element array where the first element is a headers 191 | # list (as defined in the Rack specification) and the second is a string 192 | # containing the HTTP body content. When using structured content mode, the 193 | # headers list will contain only a `Content-Type` header and the body will 194 | # contain the serialized event. When using binary mode, the header list 195 | # will contain the serialized event attributes and the body will contain 196 | # the serialized event data. 197 | # 198 | # @param event [CloudEvents::Event,Array,CloudEvents::Event::Opaque] 199 | # The event, batch, or opaque event. 200 | # @param structured_format [boolean,String] If given, the data will be 201 | # encoded in structured content mode. You can pass a string to select 202 | # a format name, or pass `true` to use the default format. If set to 203 | # `false` (the default), the data will be encoded in binary mode. 204 | # @param format_args [keywords] Extra args to pass to the formatter. 205 | # @return [Array(headers,String)] 206 | # 207 | def encode_event(event, structured_format: false, **format_args) 208 | if event.is_a?(Event::Opaque) 209 | [{ "Content-Type" => event.content_type.to_s }, event.content] 210 | elsif !structured_format 211 | if event.is_a?(::Array) 212 | raise(::ArgumentError, "Encoding a batch requires structured_format") 213 | end 214 | encode_binary_content(event, legacy_data_encode: false, **format_args) 215 | else 216 | structured_format = default_encoder_name if structured_format == true 217 | raise(::ArgumentError, "Format name not specified, and no default is set") unless structured_format 218 | case event 219 | when ::Array 220 | encode_batched_content(event, structured_format, **format_args) 221 | when Event 222 | encode_structured_content(event, structured_format, **format_args) 223 | else 224 | raise(::ArgumentError, "Unknown event type: #{event.class}") 225 | end 226 | end 227 | end 228 | 229 | ## 230 | # Decode an event from the given Rack environment hash. Following the 231 | # CloudEvents spec, this chooses a handler based on the Content-Type of 232 | # the request. 233 | # 234 | # @deprecated Will be removed in version 1.0. Use {#decode_event} instead. 235 | # 236 | # @param env [Hash] The Rack environment. 237 | # @param format_args [keywords] Extra args to pass to the formatter. 238 | # @return [CloudEvents::Event] if the request includes a single structured 239 | # or binary event. 240 | # @return [Array] if the request includes a batch of 241 | # structured events. 242 | # @return [nil] if the request does not appear to be a CloudEvent. 243 | # @raise [CloudEvents::CloudEventsError] if the request appears to be a 244 | # CloudEvent but decoding failed. 245 | # 246 | def decode_rack_env(env, **format_args) 247 | content_type_string = env["CONTENT_TYPE"] 248 | content_type = ContentType.new(content_type_string) if content_type_string 249 | content = read_with_charset(env["rack.input"], content_type&.charset) 250 | env["rack.input"].rewind rescue nil 251 | decode_binary_content(content, content_type, env, true, **format_args) || 252 | decode_structured_content(content, content_type, false, **format_args) 253 | end 254 | 255 | ## 256 | # Encode a single event in structured content mode in the given format. 257 | # 258 | # @deprecated Will be removed in version 1.0. Use {#encode_event} instead. 259 | # 260 | # @param event [CloudEvents::Event] The event. 261 | # @param format_name [String] The format name. 262 | # @param format_args [keywords] Extra args to pass to the formatter. 263 | # @return [Array(headers,String)] 264 | # 265 | def encode_structured_content(event, format_name, **format_args) 266 | result = @event_encoders[format_name]&.encode_event(event: event, 267 | data_encoder: @data_encoders, 268 | **format_args) 269 | return [{ "Content-Type" => result[:content_type].to_s }, result[:content]] if result 270 | raise(::ArgumentError, "Unknown format name: #{format_name.inspect}") 271 | end 272 | 273 | ## 274 | # Encode a batch of events in structured content mode in the given format. 275 | # 276 | # @deprecated Will be removed in version 1.0. Use {#encode_event} instead. 277 | # 278 | # @param event_batch [Array] The batch of events. 279 | # @param format_name [String] The format name. 280 | # @param format_args [keywords] Extra args to pass to the formatter. 281 | # @return [Array(headers,String)] 282 | # 283 | def encode_batched_content(event_batch, format_name, **format_args) 284 | result = @event_encoders[format_name]&.encode_event(event_batch: event_batch, 285 | data_encoder: @data_encoders, 286 | **format_args) 287 | return [{ "Content-Type" => result[:content_type].to_s }, result[:content]] if result 288 | raise(::ArgumentError, "Unknown format name: #{format_name.inspect}") 289 | end 290 | 291 | ## 292 | # Encode an event in binary content mode. 293 | # 294 | # @deprecated Will be removed in version 1.0. Use {#encode_event} instead. 295 | # 296 | # @param event [CloudEvents::Event] The event. 297 | # @param format_args [keywords] Extra args to pass to the formatter. 298 | # @return [Array(headers,String)] 299 | # 300 | def encode_binary_content(event, legacy_data_encode: true, **format_args) 301 | headers = {} 302 | event.to_h.each do |key, value| 303 | unless ["data", "data_encoded", "datacontenttype"].include?(key) 304 | headers["CE-#{key}"] = percent_encode(value) 305 | end 306 | end 307 | body, content_type = 308 | if legacy_data_encode || event.spec_version.start_with?("0.") 309 | legacy_extract_event_data(event) 310 | else 311 | normal_extract_event_data(event, format_args) 312 | end 313 | headers["Content-Type"] = content_type.to_s if content_type 314 | [headers, body] 315 | end 316 | 317 | ## 318 | # Decode a percent-encoded string to a UTF-8 string. 319 | # 320 | # @private 321 | # 322 | # @param str [String] Incoming ascii string from an HTTP header, with one 323 | # cycle of percent-encoding. 324 | # @return [String] Resulting decoded string in UTF-8. 325 | # 326 | def percent_decode(str) 327 | str = str.gsub(/"((?:[^"\\]|\\.)*)"/) { ::Regexp.last_match(1).gsub(/\\(.)/, '\1') } 328 | decoded_str = str.gsub(/%[0-9a-fA-F]{2}/) { |m| [m[1..].to_i(16)].pack("C") } 329 | decoded_str.force_encoding(::Encoding::UTF_8) 330 | end 331 | 332 | ## 333 | # Transcode an arbitrarily-encoded string to UTF-8, then percent-encode 334 | # non-printing and non-ascii characters to result in an ASCII string 335 | # suitable for setting as an HTTP header value. 336 | # 337 | # @private 338 | # 339 | # @param str [String] Incoming arbitrary string that can be represented 340 | # in UTF-8. 341 | # @return [String] Resulting encoded string in ASCII. 342 | # 343 | def percent_encode(str) 344 | arr = [] 345 | utf_str = str.to_s.encode(::Encoding::UTF_8) 346 | utf_str.each_byte do |byte| 347 | if byte >= 33 && byte <= 126 && byte != 34 && byte != 37 348 | arr << byte 349 | else 350 | hi = byte / 16 351 | hi = hi > 9 ? 55 + hi : 48 + hi 352 | lo = byte % 16 353 | lo = lo > 9 ? 55 + lo : 48 + lo 354 | arr << 37 << hi << lo 355 | end 356 | end 357 | arr.pack("C*") 358 | end 359 | 360 | private 361 | 362 | def add_named_formatter(collection, formatter, name) 363 | return unless name 364 | formatters = collection[name] ||= [] 365 | formatters.unshift(formatter) unless formatters.include?(formatter) 366 | end 367 | 368 | ## 369 | # Decode a single event from the given request body and content type in 370 | # structured mode. 371 | # 372 | def decode_structured_content(content, content_type, allow_opaque, **format_args) 373 | result = @event_decoders.decode_event(content: content, 374 | content_type: content_type, 375 | data_decoder: @data_decoders, 376 | **format_args) 377 | return result[:event] || result[:event_batch] if result 378 | if content_type&.media_type == "application" && 379 | ["cloudevents", "cloudevents-batch"].include?(content_type.subtype_base) 380 | return Event::Opaque.new(content, content_type) if allow_opaque 381 | raise(UnsupportedFormatError, "Unknown cloudevents content type: #{content_type}") 382 | end 383 | nil 384 | end 385 | 386 | ## 387 | # Decode an event from the given Rack environment in binary content mode. 388 | # 389 | # TODO: legacy_data_decode is deprecated and can be removed when 390 | # decode_rack_env is removed. 391 | # 392 | def decode_binary_content(content, content_type, env, legacy_data_decode, **format_args) 393 | spec_version = env["HTTP_CE_SPECVERSION"] 394 | return nil unless spec_version 395 | unless spec_version =~ /^0\.3|1(\.|$)/ 396 | raise(SpecVersionError, "Unrecognized specversion: #{spec_version}") 397 | end 398 | attributes = { "spec_version" => spec_version } 399 | if legacy_data_decode || spec_version.start_with?("0.") 400 | legacy_populate_data_attributes(attributes, content, content_type) 401 | else 402 | normal_populate_data_attributes(attributes, content, content_type, spec_version, format_args) 403 | end 404 | populate_attributes_from_env(attributes, env) 405 | Event.create(spec_version: spec_version, set_attributes: attributes) 406 | end 407 | 408 | def legacy_populate_data_attributes(attributes, content, content_type) 409 | attributes["data"] = content 410 | attributes["data_content_type"] = content_type if content_type 411 | end 412 | 413 | def normal_populate_data_attributes(attributes, content, content_type, spec_version, format_args) 414 | attributes["data_encoded"] = content 415 | result = @data_decoders.decode_data(spec_version: spec_version, 416 | content: content, 417 | content_type: content_type, 418 | **format_args) 419 | if result 420 | attributes["data"] = result[:data] 421 | content_type = result[:content_type] 422 | end 423 | attributes["data_content_type"] = content_type if content_type 424 | end 425 | 426 | def populate_attributes_from_env(attributes, env) 427 | omit_names = ["specversion", "spec_version", "data", "datacontenttype", "data_content_type"] 428 | env.each do |key, value| 429 | match = /^HTTP_CE_(\w+)$/.match(key) 430 | next unless match 431 | attr_name = match[1].downcase 432 | attributes[attr_name] = percent_decode(value) unless omit_names.include?(attr_name) 433 | end 434 | end 435 | 436 | def legacy_extract_event_data(event) 437 | body = event.data 438 | content_type = event.data_content_type&.to_s 439 | case body 440 | when ::String 441 | [body, content_type || string_content_type(body)] 442 | when nil 443 | [nil, nil] 444 | else 445 | [::JSON.dump(body), content_type || "application/json; charset=#{body.encoding.name.downcase}"] 446 | end 447 | end 448 | 449 | def normal_extract_event_data(event, format_args) 450 | body = event.data_encoded 451 | if body 452 | [body, event.data_content_type] 453 | elsif event.data? 454 | result = @data_encoders.encode_data(spec_version: event.spec_version, 455 | data: event.data, 456 | content_type: event.data_content_type, 457 | **format_args) 458 | raise(UnsupportedFormatError, "Could not encode unknown content-type: #{content_type}") unless result 459 | [result[:content], result[:content_type]] 460 | else 461 | ["", nil] 462 | end 463 | end 464 | 465 | def read_with_charset(io, charset) 466 | return nil if io.nil? 467 | str = io.read 468 | if charset 469 | begin 470 | str.force_encoding(charset) 471 | rescue ::ArgumentError 472 | # Use binary for now if the charset is unrecognized 473 | str.force_encoding(::Encoding::ASCII_8BIT) 474 | end 475 | end 476 | str 477 | end 478 | 479 | # @private 480 | module DefaultDataFormat 481 | # @private 482 | def self.decode_data(content: nil, content_type: nil, **_extra_kwargs) 483 | return nil unless content_type.nil? 484 | { data: content, content_type: nil } 485 | end 486 | 487 | # @private 488 | def self.encode_data(data: nil, content_type: nil, **_extra_kwargs) 489 | return nil unless content_type.nil? 490 | { content: data.to_s, content_type: nil } 491 | end 492 | end 493 | end 494 | end 495 | -------------------------------------------------------------------------------- /lib/cloud_events/json_format.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module CloudEvents 6 | ## 7 | # An implementation of JSON format and JSON batch format. 8 | # 9 | # Supports the CloudEvents 0.3 and CloudEvents 1.0 variants of this format. 10 | # See https://github.com/cloudevents/spec/blob/v0.3/json-format.md and 11 | # https://github.com/cloudevents/spec/blob/v1.0/json-format.md. 12 | # 13 | class JsonFormat 14 | # @private 15 | UNSPECIFIED = ::Object.new.freeze 16 | 17 | ## 18 | # Decode an event or batch from the given input JSON string. 19 | # See {CloudEvents::Format#decode_event} for a general description. 20 | # 21 | # Expects `:content` and `:content_type` arguments, and will decline the 22 | # request unless both are provided. 23 | # 24 | # If decoding succeeded, returns a hash with _one of_ the following keys: 25 | # 26 | # * `:event` ({CloudEvents::Event}) A single event decoded from the input. 27 | # * `:event_batch` (Array of {CloudEvents::Event}) A batch of events 28 | # decoded from the input. 29 | # 30 | # @param content [String] Serialized content to decode. 31 | # @param content_type [CloudEvents::ContentType] The input content type. 32 | # @param data_decoder [#decode_data] Optional data field decoder, used for 33 | # non-JSON content types. 34 | # @return [Hash] if accepting the request. 35 | # @return [nil] if declining the request. 36 | # @raise [CloudEvents::FormatSyntaxError] if the JSON could not be parsed 37 | # @raise [CloudEvents::SpecVersionError] if an unsupported specversion is 38 | # found. 39 | # 40 | def decode_event(content: nil, content_type: nil, data_decoder: nil, **_other_kwargs) 41 | return nil unless content && content_type&.media_type == "application" && content_type&.subtype_format == "json" 42 | case content_type.subtype_base 43 | when "cloudevents" 44 | event = decode_hash_structure(::JSON.parse(content), charset: charset_of(content), data_decoder: data_decoder) 45 | { event: event } 46 | when "cloudevents-batch" 47 | charset = charset_of(content) 48 | batch = Array(::JSON.parse(content)).map do |structure| 49 | decode_hash_structure(structure, charset: charset, data_decoder: data_decoder) 50 | end 51 | { event_batch: batch } 52 | end 53 | rescue ::JSON::JSONError 54 | raise(FormatSyntaxError, "JSON syntax error") 55 | end 56 | 57 | ## 58 | # Encode an event or batch to a JSON string. This formatter should be able 59 | # to handle any event. 60 | # See {CloudEvents::Format#decode_event} for a general description. 61 | # 62 | # Expects _either_ the `:event` _or_ the `:event_batch` argument, but not 63 | # both, and will decline the request unless exactly one is provided. 64 | # 65 | # If encoding succeeded, returns a hash with the following keys: 66 | # 67 | # * `:content` (String) The serialized form of the event or batch. 68 | # * `:content_type` ({CloudEvents::ContentType}) The content type for the 69 | # output. 70 | # 71 | # @param event [CloudEvents::Event] An event to encode. 72 | # @param event_batch [Array] An event batch to encode. 73 | # @param data_encoder [#encode_data] Optional data field encoder, used for 74 | # non-JSON content types. 75 | # @param sort [boolean] Whether to sort keys of the JSON output. 76 | # @return [Hash] if accepting the request. 77 | # @return [nil] if declining the request. 78 | # @raise [CloudEvents::FormatSyntaxError] if the JSON could not be parsed 79 | # 80 | def encode_event(event: nil, event_batch: nil, data_encoder: nil, sort: false, **_other_kwargs) 81 | if event && !event_batch 82 | structure = encode_hash_structure(event, data_encoder: data_encoder) 83 | structure = sort_keys(structure) if sort 84 | subtype = "cloudevents" 85 | elsif event_batch && !event 86 | structure = event_batch.map do |elem| 87 | structure_elem = encode_hash_structure(elem, data_encoder: data_encoder) 88 | sort ? sort_keys(structure_elem) : structure_elem 89 | end 90 | subtype = "cloudevents-batch" 91 | else 92 | return nil 93 | end 94 | content = ::JSON.dump(structure) 95 | content_type = ContentType.new("application/#{subtype}+json; charset=#{charset_of(content)}") 96 | { content: content, content_type: content_type } 97 | rescue ::JSON::JSONError 98 | raise(FormatSyntaxError, "JSON syntax error") 99 | end 100 | 101 | ## 102 | # Decode an event data object from a JSON formatted string. 103 | # See {CloudEvents::Format#decode_data} for a general description. 104 | # 105 | # Expects `:spec_version`, `:content` and `:content_type` arguments, and 106 | # will decline the request unless all three are provided. 107 | # 108 | # If decoding succeeded, returns a hash with the following keys: 109 | # 110 | # * `:data` (Object) The payload object to set as the `data` attribute. 111 | # * `:content_type` ({CloudEvents::ContentType}) The content type to be set 112 | # as the `datacontenttype` attribute. 113 | # 114 | # @param content [String] Serialized content to decode. 115 | # @param content_type [CloudEvents::ContentType] The input content type. 116 | # @return [Hash] if accepting the request. 117 | # @return [nil] if declining the request. 118 | # @raise [CloudEvents::FormatSyntaxError] if the JSON could not be parsed. 119 | # @raise [CloudEvents::SpecVersionError] if an unsupported specversion is 120 | # found. 121 | # 122 | def decode_data(spec_version: nil, content: nil, content_type: nil, **_other_kwargs) 123 | return nil unless spec_version 124 | return nil unless content 125 | return nil unless json_content_type?(content_type) 126 | unless spec_version =~ /^0\.3|1(\.|$)/ 127 | raise(SpecVersionError, "Unrecognized specversion: #{spec_version}") 128 | end 129 | data = ::JSON.parse(content) 130 | { data: data, content_type: content_type } 131 | rescue ::JSON::JSONError 132 | raise(FormatSyntaxError, "JSON syntax error") 133 | end 134 | 135 | ## 136 | # Encode an event data object to a JSON formatted string. 137 | # See {CloudEvents::Format#encode_data} for a general description. 138 | # 139 | # Expects `:spec_version`, `:data` and `:content_type` arguments, and will 140 | # decline the request unless all three are provided. 141 | # The `:data` object can be any Ruby object that can be interpreted as 142 | # JSON. Most Ruby objects will work, but normally it will be a JSON value 143 | # type comprising hashes, arrays, strings, numbers, booleans, or nil. 144 | # 145 | # If decoding succeeded, returns a hash with the following keys: 146 | # 147 | # * `:content` (String) The serialized form of the data. 148 | # * `:content_type` ({CloudEvents::ContentType}) The content type for the 149 | # output. 150 | # 151 | # @param data [Object] A data object to encode. 152 | # @param content_type [CloudEvents::ContentType] The input content type 153 | # @param sort [boolean] Whether to sort keys of the JSON output. 154 | # @return [Hash] if accepting the request. 155 | # @return [nil] if declining the request. 156 | # 157 | def encode_data(spec_version: nil, data: UNSPECIFIED, content_type: nil, sort: false, **_other_kwargs) 158 | return nil unless spec_version 159 | return nil if data == UNSPECIFIED 160 | return nil unless json_content_type?(content_type) 161 | unless spec_version =~ /^0\.3|1(\.|$)/ 162 | raise(SpecVersionError, "Unrecognized specversion: #{spec_version}") 163 | end 164 | data = sort_keys(data) if sort 165 | content = ::JSON.dump(data) 166 | { content: content, content_type: content_type } 167 | end 168 | 169 | ## 170 | # Decode a single event from a hash data structure with keys and types 171 | # conforming to the JSON envelope. 172 | # 173 | # @param structure [Hash] An input hash. 174 | # @param charset [String] The charset of the original encoded JSON document 175 | # if known. Used to provide default content types. 176 | # @param data_decoder [#decode_data] Optional data field decoder, used for 177 | # non-JSON content types. 178 | # @return [CloudEvents::Event] 179 | # 180 | def decode_hash_structure(structure, charset: nil, data_decoder: nil) 181 | spec_version = structure["specversion"].to_s 182 | case spec_version 183 | when "0.3" 184 | decode_hash_structure_v0(structure, charset) 185 | when /^1(\.|$)/ 186 | decode_hash_structure_v1(structure, charset, spec_version, data_decoder) 187 | else 188 | raise(SpecVersionError, "Unrecognized specversion: #{spec_version}") 189 | end 190 | end 191 | 192 | ## 193 | # Encode a single event to a hash data structure with keys and types 194 | # conforming to the JSON envelope. 195 | # 196 | # @param event [CloudEvents::Event] An input event. 197 | # @param data_encoder [#encode_data] Optional data field encoder, used for 198 | # non-JSON content types. 199 | # @return [String] The hash structure. 200 | # 201 | def encode_hash_structure(event, data_encoder: nil) 202 | case event 203 | when Event::V0 204 | encode_hash_structure_v0(event) 205 | when Event::V1 206 | encode_hash_structure_v1(event, data_encoder) 207 | else 208 | raise(SpecVersionError, "Unrecognized event type: #{event.class}") 209 | end 210 | end 211 | 212 | private 213 | 214 | def json_content_type?(content_type) 215 | content_type&.subtype_base == "json" || content_type&.subtype_format == "json" 216 | end 217 | 218 | def sort_keys(obj) 219 | return obj unless obj.is_a?(::Hash) 220 | result = {} 221 | obj.keys.sort.each do |key| 222 | result[key] = sort_keys(obj[key]) 223 | end 224 | result 225 | end 226 | 227 | def charset_of(str) 228 | encoding = str.encoding 229 | if encoding == ::Encoding::ASCII_8BIT 230 | "binary" 231 | else 232 | encoding.name.downcase 233 | end 234 | end 235 | 236 | def decode_hash_structure_v0(structure, charset) 237 | unless structure.key?("datacontenttype") 238 | structure = structure.dup 239 | content_type = "application/json" 240 | content_type = "#{content_type}; charset=#{charset}" if charset 241 | structure["datacontenttype"] = content_type 242 | end 243 | Event::V0.new(attributes: structure) 244 | end 245 | 246 | def decode_hash_structure_v1(structure, charset, spec_version, data_decoder) 247 | unless structure.key?("data") || structure.key?("data_base64") 248 | return Event::V1.new(set_attributes: structure) 249 | end 250 | structure = structure.dup 251 | content, content_type = retrieve_content_from_data_fields(structure, charset) 252 | populate_data_fields_from_content(structure, content, content_type, spec_version, data_decoder) 253 | Event::V1.new(set_attributes: structure) 254 | end 255 | 256 | def retrieve_content_from_data_fields(structure, charset) 257 | if structure.key?("data_base64") 258 | content = structure.delete("data_base64").unpack1("m") 259 | content_type = structure["datacontenttype"] || "application/octet-stream" 260 | else 261 | content = structure["data"] 262 | content_type = structure["datacontenttype"] 263 | content_type ||= charset ? "application/json; charset=#{charset}" : "application/json" 264 | end 265 | [content, ContentType.new(content_type)] 266 | end 267 | 268 | def populate_data_fields_from_content(structure, content, content_type, spec_version, data_decoder) 269 | if json_content_type?(content_type) 270 | structure["data_encoded"] = ::JSON.dump(content) 271 | structure["data"] = content 272 | else 273 | structure["data_encoded"] = content = content.to_s 274 | result = data_decoder&.decode_data(spec_version: spec_version, content: content, content_type: content_type) 275 | if result 276 | structure["data"] = result[:data] 277 | content_type = result[:content_type] 278 | else 279 | structure.delete("data") 280 | end 281 | end 282 | structure["datacontenttype"] = content_type 283 | end 284 | 285 | def encode_hash_structure_v0(event) 286 | structure = event.to_h 287 | structure["datacontenttype"] ||= "application/json" 288 | structure 289 | end 290 | 291 | def encode_hash_structure_v1(event, data_encoder) 292 | structure = event.to_h 293 | return structure unless structure.key?("data") || structure.key?("data_encoded") 294 | content_type = event.data_content_type 295 | if content_type.nil? || json_content_type?(content_type) 296 | encode_data_fields_for_json_content(structure, event) 297 | else 298 | encode_data_fields_for_other_content(structure, event, data_encoder) 299 | end 300 | structure 301 | end 302 | 303 | def encode_data_fields_for_json_content(structure, event) 304 | structure["data"] = ::JSON.parse(event.data) unless event.data_decoded? 305 | structure.delete("data_encoded") 306 | structure["datacontenttype"] ||= "application/json" 307 | end 308 | 309 | def encode_data_fields_for_other_content(structure, event, data_encoder) 310 | data_encoded = structure.delete("data_encoded") 311 | unless data_encoded 312 | result = data_encoder&.encode_data(spec_version: event.spec_version, 313 | data: event.data, 314 | content_type: event.data_content_type) 315 | raise(UnsupportedFormatError, "Unable to encode data of media type #{event.data_content_type}") unless result 316 | data_encoded = result[:content] 317 | structure["datacontenttype"] = result[:content_type].to_s 318 | end 319 | if data_encoded.encoding == ::Encoding::ASCII_8BIT 320 | structure["data_base64"] = [data_encoded].pack("m0") 321 | structure.delete("data") 322 | else 323 | structure["data"] = data_encoded 324 | end 325 | end 326 | end 327 | end 328 | -------------------------------------------------------------------------------- /lib/cloud_events/text_format.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module CloudEvents 6 | ## 7 | # An data encoder/decoder for text content types. This handles any media type 8 | # of the form `text/*` or `application/octet-stream`, and passes strings 9 | # through as-is. 10 | # 11 | class TextFormat 12 | # @private 13 | UNSPECIFIED = ::Object.new.freeze 14 | 15 | ## 16 | # Trivially decode an event data string using text format. 17 | # See {CloudEvents::Format#decode_data} for a general description. 18 | # 19 | # Expects `:content` and `:content_type` arguments, and will decline the 20 | # request unless all three are provided. 21 | # 22 | # If decoding succeeded, returns a hash with the following keys: 23 | # 24 | # * `:data` (Object) The payload object to set as the `data` attribute. 25 | # * `:content_type` ({CloudEvents::ContentType}) The content type to be set 26 | # as the `datacontenttype` attribute. 27 | # 28 | # @param content [String] Serialized content to decode. 29 | # @param content_type [CloudEvents::ContentType] The input content type. 30 | # @return [Hash] if accepting the request. 31 | # @return [nil] if declining the request. 32 | # 33 | def decode_data(content: nil, content_type: nil, **_other_kwargs) 34 | return nil unless content 35 | return nil unless text_content_type?(content_type) 36 | { data: content.to_s, content_type: content_type } 37 | end 38 | 39 | ## 40 | # Trivially encode an event data object using text format. 41 | # See {CloudEvents::Format#encode_data} for a general description. 42 | # 43 | # Expects `:data` and `:content_type` arguments, and will decline the 44 | # request unless all three are provided. 45 | # The `:data` object will be converted to a string if it is not already a 46 | # string. 47 | # 48 | # If decoding succeeded, returns a hash with the following keys: 49 | # 50 | # * `:content` (String) The serialized form of the data. 51 | # * `:content_type` ({CloudEvents::ContentType}) The content type for the 52 | # output. 53 | # 54 | # @param data [Object] A data object to encode. 55 | # @param content_type [CloudEvents::ContentType] The input content type 56 | # @return [Hash] if accepting the request. 57 | # @return [nil] if declining the request. 58 | # 59 | def encode_data(data: UNSPECIFIED, content_type: nil, **_other_kwargs) 60 | return nil if data == UNSPECIFIED 61 | return nil unless text_content_type?(content_type) 62 | { content: data.to_s, content_type: content_type } 63 | end 64 | 65 | private 66 | 67 | def text_content_type?(content_type) 68 | content_type&.media_type == "text" || 69 | (content_type&.media_type == "application" && content_type&.subtype == "octet-stream") 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/cloud_events/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudEvents 4 | ## 5 | # Version of the Ruby CloudEvents SDK 6 | # @return [String] 7 | # 8 | VERSION = "0.8.2" 9 | end 10 | -------------------------------------------------------------------------------- /test/event/test_opaque.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../helper" 4 | 5 | describe CloudEvents::Event::Opaque do 6 | let(:my_content_type_string) { "text/plain; charset=us-ascii" } 7 | let(:my_content_type) { CloudEvents::ContentType.new(my_content_type_string) } 8 | let(:my_content) { "12345" } 9 | 10 | it "handles non-batch" do 11 | event = CloudEvents::Event::Opaque.new(my_content, my_content_type) 12 | assert_equal my_content, event.content 13 | assert_equal my_content_type, event.content_type 14 | refute event.batch? 15 | assert Ractor.shareable?(event) if defined? Ractor 16 | end 17 | 18 | it "handles batch" do 19 | event = CloudEvents::Event::Opaque.new(my_content, my_content_type, batch: true) 20 | assert_equal my_content, event.content 21 | assert_equal my_content_type, event.content_type 22 | assert event.batch? 23 | assert Ractor.shareable?(event) if defined? Ractor 24 | end 25 | 26 | it "checks equality" do 27 | event1 = CloudEvents::Event::Opaque.new(my_content, my_content_type) 28 | event2 = CloudEvents::Event::Opaque.new(my_content, my_content_type) 29 | event3 = CloudEvents::Event::Opaque.new(my_content, my_content_type, batch: true) 30 | assert_equal event1, event2 31 | refute_equal event1, event3 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/event/test_v0.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../helper" 4 | 5 | require "date" 6 | require "uri" 7 | 8 | describe CloudEvents::Event::V0 do 9 | let(:my_id) { "my_id" } 10 | let(:my_source_string) { "/my_source" } 11 | let(:my_source) { URI.parse(my_source_string) } 12 | let(:my_source2_string) { "/my_source2" } 13 | let(:my_source2) { URI.parse(my_source2_string) } 14 | let(:my_type) { "my_type" } 15 | let(:my_type2) { "my_type2" } 16 | let(:spec_version) { "0.3" } 17 | let(:my_simple_data) { "12345" } 18 | let(:my_content_encoding) { "base64" } 19 | let(:my_content_type_string) { "text/plain; charset=us-ascii" } 20 | let(:my_content_type) { CloudEvents::ContentType.new(my_content_type_string) } 21 | let(:my_schema_string) { "/my_schema" } 22 | let(:my_schema) { URI.parse(my_schema_string) } 23 | let(:my_subject) { "my_subject" } 24 | let(:my_time_string) { "2020-01-12T20:52:05-08:00" } 25 | let(:my_date_time) { DateTime.rfc3339(my_time_string) } 26 | let(:my_time) { my_date_time.to_time } 27 | let(:my_trace_parent) { "12345678" } 28 | 29 | it "handles string inputs" do 30 | event = CloudEvents::Event::V0.new(id: my_id, 31 | source: my_source_string, 32 | type: my_type, 33 | spec_version: spec_version, 34 | data: my_simple_data, 35 | data_content_encoding: my_content_encoding, 36 | data_content_type: my_content_type_string, 37 | schema_url: my_schema_string, 38 | subject: my_subject, 39 | time: my_time_string) 40 | assert_equal my_id, event.id 41 | assert_equal my_source, event.source 42 | assert_equal my_type, event.type 43 | assert_equal spec_version, event.spec_version 44 | assert_equal my_simple_data, event.data 45 | assert_equal my_content_encoding, event.data_content_encoding 46 | assert_equal my_content_type, event.data_content_type 47 | assert_equal my_schema, event.schema_url 48 | assert_equal my_subject, event.subject 49 | assert_equal my_date_time, event.time 50 | assert_equal my_id, event[:id] 51 | assert_equal my_source_string, event[:source] 52 | assert_equal my_type, event[:type] 53 | assert_equal spec_version, event[:specversion] 54 | assert_nil event[:spec_version] 55 | assert_equal my_simple_data, event[:data] 56 | assert_equal my_content_encoding, event[:datacontentencoding] 57 | assert_nil event[:data_content_encoding] 58 | assert_equal my_content_type_string, event[:datacontenttype] 59 | assert_nil event[:data_content_type] 60 | assert_equal my_schema_string, event[:schemaurl] 61 | assert_nil event[:schema_url] 62 | assert_equal my_subject, event[:subject] 63 | assert_equal my_time_string, event[:time] 64 | assert Ractor.shareable?(event) if defined? Ractor 65 | end 66 | 67 | it "handles object inputs" do 68 | event = CloudEvents::Event::V0.new(id: my_id, 69 | source: my_source, 70 | type: my_type, 71 | spec_version: spec_version, 72 | data: my_simple_data, 73 | data_content_type: my_content_type, 74 | schema_url: my_schema, 75 | subject: my_subject, 76 | time: my_date_time) 77 | assert_equal my_id, event.id 78 | assert_equal my_source, event.source 79 | assert_equal my_type, event.type 80 | assert_equal spec_version, event.spec_version 81 | assert_equal my_simple_data, event.data 82 | assert_equal my_content_type, event.data_content_type 83 | assert_equal my_schema, event.schema_url 84 | assert_equal my_subject, event.subject 85 | assert_equal my_date_time, event.time 86 | assert_equal my_id, event[:id] 87 | assert_equal my_source_string, event[:source] 88 | assert_equal my_type, event[:type] 89 | assert_equal spec_version, event[:specversion] 90 | assert_nil event[:spec_version] 91 | assert_equal my_simple_data, event[:data] 92 | assert_equal my_content_type_string, event[:datacontenttype] 93 | assert_nil event[:data_content_type] 94 | assert_equal my_schema_string, event[:schemaurl] 95 | assert_nil event[:schema_url] 96 | assert_equal my_subject, event[:subject] 97 | assert_equal my_time_string, event[:time] 98 | assert Ractor.shareable?(event) if defined? Ractor 99 | end 100 | 101 | it "handles more object inputs" do 102 | event = CloudEvents::Event::V0.new(id: my_id, 103 | source: my_source, 104 | type: my_type, 105 | spec_version: spec_version, 106 | data: my_simple_data, 107 | time: my_time) 108 | assert_equal my_id, event.id 109 | assert_equal my_source, event.source 110 | assert_equal my_type, event.type 111 | assert_equal spec_version, event.spec_version 112 | assert_equal my_simple_data, event.data 113 | assert_equal my_date_time, event.time 114 | assert Ractor.shareable?(event) if defined? Ractor 115 | end 116 | 117 | it "sets defaults when optional inputs are omitted" do 118 | event = CloudEvents::Event::V0.new(id: my_id, 119 | source: my_source, 120 | type: my_type, 121 | spec_version: spec_version) 122 | assert_equal my_id, event.id 123 | assert_equal my_source, event.source 124 | assert_equal my_type, event.type 125 | assert_equal spec_version, event.spec_version 126 | assert_nil event.data 127 | assert_nil event.data_content_encoding 128 | assert_nil event.data_content_type 129 | assert_nil event.schema_url 130 | assert_nil event.subject 131 | assert_nil event.time 132 | assert_equal my_id, event[:id] 133 | assert_equal my_source_string, event[:source] 134 | assert_equal my_type, event[:type] 135 | assert_equal spec_version, event[:specversion] 136 | assert_nil event[:spec_version] 137 | assert_nil event[:data] 138 | assert_nil event[:datacontentencoding] 139 | assert_nil event[:data_content_encoding] 140 | assert_nil event[:datacontenttype] 141 | assert_nil event[:data_content_type] 142 | assert_nil event[:schemaurl] 143 | assert_nil event[:schema_url] 144 | assert_nil event[:subject] 145 | assert_nil event[:time] 146 | assert Ractor.shareable?(event) if defined? Ractor 147 | end 148 | 149 | it "creates a modified copy" do 150 | event = CloudEvents::Event::V0.new(id: my_id, 151 | source: my_source_string, 152 | type: my_type, 153 | spec_version: spec_version, 154 | data: my_simple_data, 155 | data_content_encoding: my_content_encoding, 156 | data_content_type: my_content_type_string, 157 | schema_url: my_schema_string, 158 | subject: my_subject, 159 | time: my_time_string) 160 | event2 = event.with(type: my_type2, source: my_source2) 161 | assert_equal my_id, event2.id 162 | assert_equal my_source2, event2.source 163 | assert_equal my_type2, event2.type 164 | assert_equal my_schema, event2.schema_url 165 | assert Ractor.shareable?(event2) if defined? Ractor 166 | end 167 | 168 | it "requires specversion" do 169 | error = assert_raises(CloudEvents::AttributeError) do 170 | CloudEvents::Event::V0.new(id: my_id, 171 | source: my_source, 172 | type: my_type) 173 | end 174 | assert_equal "The specversion field is required", error.message 175 | end 176 | 177 | it "errors when the wrong specversion is given" do 178 | error = assert_raises(CloudEvents::SpecVersionError) do 179 | CloudEvents::Event::V0.new(id: my_id, 180 | source: my_source, 181 | type: my_type, 182 | spec_version: "1.0") 183 | end 184 | assert_equal "Unrecognized specversion: 1.0", error.message 185 | end 186 | 187 | it "requires id" do 188 | error = assert_raises(CloudEvents::AttributeError) do 189 | CloudEvents::Event::V0.new(source: my_source, 190 | type: my_type, 191 | spec_version: spec_version) 192 | end 193 | assert_equal "The id field is required", error.message 194 | end 195 | 196 | it "requires source" do 197 | error = assert_raises(CloudEvents::AttributeError) do 198 | CloudEvents::Event::V0.new(id: my_id, 199 | type: my_type, 200 | spec_version: spec_version) 201 | end 202 | assert_equal "The source field is required", error.message 203 | end 204 | 205 | it "requires type" do 206 | error = assert_raises(CloudEvents::AttributeError) do 207 | CloudEvents::Event::V0.new(id: my_id, 208 | source: my_source, 209 | spec_version: spec_version) 210 | end 211 | assert_equal "The type field is required", error.message 212 | end 213 | 214 | it "validates attribute name" do 215 | error = assert_raises(CloudEvents::AttributeError) do 216 | CloudEvents::Event::V0.new(id: my_id, 217 | source: my_source, 218 | type: my_type, 219 | spec_version: spec_version, 220 | "1parent": my_trace_parent) 221 | end 222 | assert_includes error.message, "Illegal key: \"1parent\"" 223 | error = assert_raises(CloudEvents::AttributeError) do 224 | CloudEvents::Event::V0.new(id: my_id, 225 | source: my_source, 226 | type: my_type, 227 | spec_version: spec_version, 228 | trace_parent: my_trace_parent) 229 | end 230 | assert_includes error.message, "Illegal key: \"trace_parent\"" 231 | end 232 | 233 | it "handles extension attributes" do 234 | event = CloudEvents::Event::V0.new(id: my_id, 235 | source: my_source, 236 | type: my_type, 237 | spec_version: spec_version, 238 | traceparent: my_trace_parent) 239 | assert_equal my_trace_parent, event[:traceparent] 240 | assert_equal my_trace_parent, event.to_h["traceparent"] 241 | end 242 | 243 | it "handles nonstring extension attributes" do 244 | event = CloudEvents::Event::V0.new(id: my_id, 245 | source: my_source, 246 | type: my_type, 247 | spec_version: spec_version, 248 | dataref: my_source) 249 | assert_equal my_source_string, event[:dataref] 250 | assert_equal my_source_string, event.to_h["dataref"] 251 | end 252 | 253 | it "handles nil extension attributes" do 254 | event = CloudEvents::Event::V0.new(id: my_id, 255 | source: my_source, 256 | type: my_type, 257 | spec_version: spec_version, 258 | traceparent: nil) 259 | assert_nil event[:traceparent] 260 | refute_includes event.to_h, "traceparent" 261 | end 262 | 263 | it "returns a deep copy from to_h" do 264 | my_data = { "a" => [1, 2, 3, 4] } 265 | event = CloudEvents::Event::V0.new(id: my_id, 266 | source: my_source_string, 267 | type: my_type, 268 | spec_version: spec_version, 269 | data: my_data) 270 | assert Ractor.shareable?(event) if defined? Ractor 271 | 272 | data_from_getter = event.data 273 | assert_equal my_data, data_from_getter 274 | assert data_from_getter.frozen? 275 | assert data_from_getter["a"].frozen? 276 | 277 | data_from_hash = event.to_h["data"] 278 | assert_equal my_data, data_from_hash 279 | refute data_from_hash.frozen? 280 | refute data_from_hash["a"].frozen? 281 | end 282 | 283 | it "checks equality" do 284 | event1 = CloudEvents::Event::V0.new(id: my_id, 285 | source: my_source, 286 | type: my_type, 287 | spec_version: spec_version) 288 | event2 = CloudEvents::Event::V0.new(id: my_id, 289 | source: my_source, 290 | type: my_type, 291 | spec_version: spec_version) 292 | event3 = CloudEvents::Event::V0.new(id: my_id, 293 | source: my_source2, 294 | type: my_type, 295 | spec_version: spec_version) 296 | assert_equal event1, event2 297 | refute_equal event1, event3 298 | end 299 | end 300 | -------------------------------------------------------------------------------- /test/event/test_v1.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../helper" 4 | 5 | require "date" 6 | require "uri" 7 | 8 | describe CloudEvents::Event::V1 do 9 | let(:my_id) { "my_id" } 10 | let(:my_source_string) { "/my_source" } 11 | let(:my_source) { URI.parse(my_source_string) } 12 | let(:my_source2_string) { "/my_source2" } 13 | let(:my_source2) { URI.parse(my_source2_string) } 14 | let(:my_type) { "my_type" } 15 | let(:my_type2) { "my_type2" } 16 | let(:spec_version) { "1.0" } 17 | let(:my_simple_data) { "12345" } 18 | let(:my_content_type_string) { "text/plain; charset=us-ascii" } 19 | let(:my_content_type) { CloudEvents::ContentType.new(my_content_type_string) } 20 | let(:my_object_data) { { "foo" => "bar" } } 21 | let(:my_object_data_encoded) { '{"foo":"bar"}' } 22 | let(:my_json_content_type_string) { "application/json; charset=us-ascii" } 23 | let(:my_json_content_type) { CloudEvents::ContentType.new(my_json_content_type_string) } 24 | let(:my_schema_string) { "/my_schema" } 25 | let(:my_schema) { URI.parse(my_schema_string) } 26 | let(:my_subject) { "my_subject" } 27 | let(:my_time_string) { "2020-01-12T20:52:05-08:00" } 28 | let(:my_date_time) { DateTime.rfc3339(my_time_string) } 29 | let(:my_time) { my_date_time.to_time } 30 | let(:my_trace_parent) { "12345678" } 31 | 32 | it "handles string inputs" do 33 | event = CloudEvents::Event::V1.new(id: my_id, 34 | source: my_source_string, 35 | type: my_type, 36 | spec_version: spec_version, 37 | data: my_simple_data, 38 | data_encoded: my_simple_data, 39 | data_content_type: my_content_type_string, 40 | data_schema: my_schema_string, 41 | subject: my_subject, 42 | time: my_time_string) 43 | assert_equal my_id, event.id 44 | assert_equal my_source, event.source 45 | assert_equal my_type, event.type 46 | assert_equal spec_version, event.spec_version 47 | assert_equal my_simple_data, event.data 48 | assert_equal my_simple_data, event.data_encoded 49 | assert event.data_decoded? 50 | assert_equal my_content_type, event.data_content_type 51 | assert_equal my_schema, event.data_schema 52 | assert_equal my_subject, event.subject 53 | assert_equal my_date_time, event.time 54 | assert_equal my_id, event[:id] 55 | assert_equal my_source_string, event[:source] 56 | assert_equal my_type, event[:type] 57 | assert_equal spec_version, event[:specversion] 58 | assert_nil event[:spec_version] 59 | assert_equal my_simple_data, event[:data] 60 | assert_equal my_simple_data, event[:data_encoded] 61 | assert_equal my_content_type_string, event[:datacontenttype] 62 | assert_nil event[:data_content_type] 63 | assert_equal my_schema_string, event[:dataschema] 64 | assert_nil event[:data_schema] 65 | assert_equal my_subject, event[:subject] 66 | assert_equal my_time_string, event[:time] 67 | assert Ractor.shareable?(event) if defined? Ractor 68 | end 69 | 70 | it "handles object inputs" do 71 | event = CloudEvents::Event::V1.new(id: my_id, 72 | source: my_source, 73 | type: my_type, 74 | spec_version: spec_version, 75 | data: my_object_data, 76 | data_encoded: my_object_data_encoded, 77 | data_content_type: my_json_content_type, 78 | data_schema: my_schema, 79 | subject: my_subject, 80 | time: my_date_time) 81 | assert_equal my_id, event.id 82 | assert_equal my_source, event.source 83 | assert_equal my_type, event.type 84 | assert_equal spec_version, event.spec_version 85 | assert_equal my_object_data, event.data 86 | assert_equal my_object_data_encoded, event.data_encoded 87 | assert event.data_decoded? 88 | assert_equal my_json_content_type, event.data_content_type 89 | assert_equal my_schema, event.data_schema 90 | assert_equal my_subject, event.subject 91 | assert_equal my_date_time, event.time 92 | assert_equal my_id, event[:id] 93 | assert_equal my_source_string, event[:source] 94 | assert_equal my_type, event[:type] 95 | assert_equal spec_version, event[:specversion] 96 | assert_nil event[:spec_version] 97 | assert_equal my_object_data, event[:data] 98 | assert_equal my_object_data_encoded, event[:data_encoded] 99 | assert_equal my_json_content_type_string, event[:datacontenttype] 100 | assert_nil event[:data_content_type] 101 | assert_equal my_schema_string, event[:dataschema] 102 | assert_nil event[:data_schema] 103 | assert_equal my_subject, event[:subject] 104 | assert_equal my_time_string, event[:time] 105 | assert Ractor.shareable?(event) if defined? Ractor 106 | end 107 | 108 | it "handles more object inputs" do 109 | event = CloudEvents::Event::V1.new(id: my_id, 110 | source: my_source, 111 | type: my_type, 112 | spec_version: spec_version, 113 | data: my_simple_data, 114 | time: my_time) 115 | assert_equal my_id, event.id 116 | assert_equal my_source, event.source 117 | assert_equal my_type, event.type 118 | assert_equal spec_version, event.spec_version 119 | assert_equal my_simple_data, event.data 120 | assert_equal my_date_time, event.time 121 | assert Ractor.shareable?(event) if defined? Ractor 122 | end 123 | 124 | it "sets defaults when optional inputs are omitted" do 125 | event = CloudEvents::Event::V1.new(id: my_id, 126 | source: my_source, 127 | type: my_type, 128 | spec_version: spec_version) 129 | assert_equal my_id, event.id 130 | assert_equal my_source, event.source 131 | assert_equal my_type, event.type 132 | assert_equal spec_version, event.spec_version 133 | assert_nil event.data 134 | assert_nil event.data_encoded 135 | refute event.data_decoded? 136 | assert_nil event.data_content_type 137 | assert_nil event.data_schema 138 | assert_nil event.subject 139 | assert_nil event.time 140 | assert_equal my_id, event[:id] 141 | assert_equal my_source_string, event[:source] 142 | assert_equal my_type, event[:type] 143 | assert_equal spec_version, event[:specversion] 144 | assert_nil event[:spec_version] 145 | assert_nil event[:data] 146 | assert_nil event[:data_encoded] 147 | assert_nil event[:datacontenttype] 148 | assert_nil event[:data_content_type] 149 | assert_nil event[:dataschema] 150 | assert_nil event[:data_schema] 151 | assert_nil event[:subject] 152 | assert_nil event[:time] 153 | assert_equal ["id", "source", "specversion", "type"], event.to_h.keys.sort 154 | assert Ractor.shareable?(event) if defined? Ractor 155 | end 156 | 157 | it "handles data set but not data_encoded" do 158 | event = CloudEvents::Event::V1.new(id: my_id, 159 | source: my_source_string, 160 | type: my_type, 161 | spec_version: spec_version, 162 | data: my_simple_data, 163 | data_content_type: my_content_type_string, 164 | data_schema: my_schema_string, 165 | subject: my_subject, 166 | time: my_time_string) 167 | assert_equal my_simple_data, event.data 168 | assert_nil event.data_encoded 169 | assert event.data_decoded? 170 | assert_equal my_simple_data, event[:data] 171 | assert_nil event[:data_encoded] 172 | end 173 | 174 | it "handles data_encoded set but not data" do 175 | event = CloudEvents::Event::V1.new(id: my_id, 176 | source: my_source_string, 177 | type: my_type, 178 | spec_version: spec_version, 179 | data_encoded: my_simple_data, 180 | data_content_type: my_content_type_string, 181 | data_schema: my_schema_string, 182 | subject: my_subject, 183 | time: my_time_string) 184 | assert_equal my_simple_data, event.data_encoded 185 | assert_equal my_simple_data, event.data 186 | refute event.data_decoded? 187 | assert_nil event[:data] 188 | assert_equal my_simple_data, event[:data_encoded] 189 | end 190 | 191 | it "creates a modified copy" do 192 | event = CloudEvents::Event::V1.new(id: my_id, 193 | source: my_source_string, 194 | type: my_type, 195 | spec_version: spec_version, 196 | data: my_simple_data, 197 | data_content_type: my_content_type_string, 198 | data_schema: my_schema_string, 199 | subject: my_subject, 200 | time: my_time_string) 201 | event2 = event.with(type: my_type2, source: my_source2) 202 | assert_equal my_id, event2.id 203 | assert_equal my_source2, event2.source 204 | assert_equal my_type2, event2.type 205 | assert_equal my_schema, event2.data_schema 206 | assert Ractor.shareable?(event2) if defined? Ractor 207 | end 208 | 209 | it "creates a modified copy changing the data" do 210 | event = CloudEvents::Event::V1.new(id: my_id, 211 | source: my_source_string, 212 | type: my_type, 213 | spec_version: spec_version, 214 | data_encoded: my_simple_data, 215 | data_content_type: my_content_type_string, 216 | data_schema: my_schema_string, 217 | subject: my_subject, 218 | time: my_time_string) 219 | assert_equal my_simple_data, event.data 220 | assert_equal my_simple_data, event.data_encoded 221 | refute event.data_decoded? 222 | event2 = event.with(data: my_simple_data) 223 | assert_nil event2.data_encoded 224 | assert_equal my_simple_data, event2.data 225 | assert event2.data_decoded? 226 | assert Ractor.shareable?(event2) if defined? Ractor 227 | end 228 | 229 | it "requires specversion" do 230 | error = assert_raises(CloudEvents::AttributeError) do 231 | CloudEvents::Event::V1.new(id: my_id, 232 | source: my_source, 233 | type: my_type) 234 | end 235 | assert_equal "The specversion field is required", error.message 236 | end 237 | 238 | it "errors when the wrong specversion is given" do 239 | error = assert_raises(CloudEvents::SpecVersionError) do 240 | CloudEvents::Event::V1.new(id: my_id, 241 | source: my_source, 242 | type: my_type, 243 | spec_version: "0.3") 244 | end 245 | assert_equal "Unrecognized specversion: 0.3", error.message 246 | end 247 | 248 | it "requires id" do 249 | error = assert_raises(CloudEvents::AttributeError) do 250 | CloudEvents::Event::V1.new(source: my_source, 251 | type: my_type, 252 | spec_version: spec_version) 253 | end 254 | assert_equal "The id field is required", error.message 255 | end 256 | 257 | it "requires source" do 258 | error = assert_raises(CloudEvents::AttributeError) do 259 | CloudEvents::Event::V1.new(id: my_id, 260 | type: my_type, 261 | spec_version: spec_version) 262 | end 263 | assert_equal "The source field is required", error.message 264 | end 265 | 266 | it "requires type" do 267 | error = assert_raises(CloudEvents::AttributeError) do 268 | CloudEvents::Event::V1.new(id: my_id, 269 | source: my_source, 270 | spec_version: spec_version) 271 | end 272 | assert_equal "The type field is required", error.message 273 | end 274 | 275 | it "validates attribute name" do 276 | CloudEvents::Event::V1.new(id: my_id, 277 | source: my_source, 278 | type: my_type, 279 | spec_version: spec_version, 280 | "1parent": my_trace_parent) 281 | error = assert_raises(CloudEvents::AttributeError) do 282 | CloudEvents::Event::V1.new(id: my_id, 283 | source: my_source, 284 | type: my_type, 285 | spec_version: spec_version, 286 | trace_parent: my_trace_parent) 287 | end 288 | assert_includes error.message, "Illegal key: \"trace_parent\"" 289 | end 290 | 291 | it "handles extension attributes" do 292 | event = CloudEvents::Event::V1.new(id: my_id, 293 | source: my_source, 294 | type: my_type, 295 | spec_version: spec_version, 296 | traceparent: my_trace_parent) 297 | assert_equal my_trace_parent, event[:traceparent] 298 | assert_equal my_trace_parent, event.to_h["traceparent"] 299 | end 300 | 301 | it "handles nonstring extension attributes" do 302 | event = CloudEvents::Event::V1.new(id: my_id, 303 | source: my_source, 304 | type: my_type, 305 | spec_version: spec_version, 306 | dataref: my_source) 307 | assert_equal my_source_string, event[:dataref] 308 | assert_equal my_source_string, event.to_h["dataref"] 309 | end 310 | 311 | it "handles nil extension attributes" do 312 | event = CloudEvents::Event::V1.new(id: my_id, 313 | source: my_source, 314 | type: my_type, 315 | spec_version: spec_version, 316 | traceparent: nil) 317 | assert_nil event[:traceparent] 318 | refute_includes event.to_h, "traceparent" 319 | end 320 | 321 | it "returns a deep copy from to_h" do 322 | my_data = { "a" => [1, 2, 3, 4] } 323 | event = CloudEvents::Event::V1.new(id: my_id, 324 | source: my_source_string, 325 | type: my_type, 326 | spec_version: spec_version, 327 | data: my_data) 328 | assert Ractor.shareable?(event) if defined? Ractor 329 | 330 | data_from_getter = event.data 331 | assert_equal my_data, data_from_getter 332 | assert data_from_getter.frozen? 333 | assert data_from_getter["a"].frozen? 334 | 335 | data_from_hash = event.to_h["data"] 336 | assert_equal my_data, data_from_hash 337 | refute data_from_hash.frozen? 338 | refute data_from_hash["a"].frozen? 339 | end 340 | 341 | it "checks equality" do 342 | event1 = CloudEvents::Event::V1.new(id: my_id, 343 | source: my_source, 344 | type: my_type, 345 | spec_version: spec_version) 346 | event2 = CloudEvents::Event::V1.new(id: my_id, 347 | source: my_source, 348 | type: my_type, 349 | spec_version: spec_version) 350 | event3 = CloudEvents::Event::V1.new(id: my_id, 351 | source: my_source2, 352 | type: my_type, 353 | spec_version: spec_version) 354 | assert_equal event1, event2 355 | refute_equal event1, event3 356 | end 357 | end 358 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "minitest/autorun" 4 | require "minitest/focus" 5 | require "minitest/rg" 6 | 7 | require "cloud_events" 8 | -------------------------------------------------------------------------------- /test/test_content_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | 5 | describe CloudEvents::ContentType do 6 | it "recognizes simple media type and subtype" do 7 | content_type = CloudEvents::ContentType.new("application/cloudevents") 8 | assert_equal "application", content_type.media_type 9 | assert_equal "cloudevents", content_type.subtype 10 | assert_equal "cloudevents", content_type.subtype_base 11 | assert_nil content_type.subtype_format 12 | assert Ractor.shareable?(content_type) if defined? Ractor 13 | end 14 | 15 | it "normalizes media type and subtype case" do 16 | content_type = CloudEvents::ContentType.new("Application/CloudEvents") 17 | assert_equal "application", content_type.media_type 18 | assert_equal "cloudevents", content_type.subtype 19 | assert_equal "cloudevents", content_type.subtype_base 20 | assert_nil content_type.subtype_format 21 | assert Ractor.shareable?(content_type) if defined? Ractor 22 | end 23 | 24 | it "recognizes extended subtype" do 25 | content_type = CloudEvents::ContentType.new("application/cloudevents+json") 26 | assert_equal "cloudevents+json", content_type.subtype 27 | assert_equal "cloudevents", content_type.subtype_base 28 | assert_equal "json", content_type.subtype_format 29 | assert Ractor.shareable?(content_type) if defined? Ractor 30 | end 31 | 32 | it "defaults to us-ascii charset" do 33 | content_type = CloudEvents::ContentType.new("application/json") 34 | assert_equal "us-ascii", content_type.charset 35 | assert Ractor.shareable?(content_type) if defined? Ractor 36 | end 37 | 38 | it "defaults to a given charset" do 39 | content_type = CloudEvents::ContentType.new("application/json", default_charset: "utf-8") 40 | assert_equal "utf-8", content_type.charset 41 | assert Ractor.shareable?(content_type) if defined? Ractor 42 | end 43 | 44 | it "recognizes charset param" do 45 | content_type = CloudEvents::ContentType.new("application/json; charset=utf-8") 46 | assert_equal [["charset", "utf-8"]], content_type.params 47 | assert_equal "utf-8", content_type.charset 48 | assert Ractor.shareable?(content_type) if defined? Ractor 49 | end 50 | 51 | it "recognizes quoted charset param" do 52 | content_type = CloudEvents::ContentType.new("application/json; charset=\"utf-8\"") 53 | assert_equal [["charset", "utf-8"]], content_type.params 54 | assert_equal "utf-8", content_type.charset 55 | assert Ractor.shareable?(content_type) if defined? Ractor 56 | end 57 | 58 | it "recognizes arbitrary quoted param values" do 59 | content_type = CloudEvents::ContentType.new("application/json; foo=\"hi\\\"\\\\ \" ;bar=ho") 60 | assert_equal [["foo", "hi\"\\ "], ["bar", "ho"]], content_type.params 61 | assert Ractor.shareable?(content_type) if defined? Ractor 62 | end 63 | 64 | it "handles nil content" do 65 | content_type = CloudEvents::ContentType.new(nil) 66 | assert_equal "text", content_type.media_type 67 | assert_equal "plain", content_type.subtype 68 | assert_equal "plain", content_type.subtype_base 69 | assert_nil content_type.subtype_format 70 | assert Ractor.shareable?(content_type) if defined? Ractor 71 | end 72 | 73 | it "remembers the input string" do 74 | header = "Application/CloudEvents+JSON; charset=utf-8" 75 | content_type = CloudEvents::ContentType.new(header) 76 | assert_equal header, content_type.string 77 | assert Ractor.shareable?(content_type) if defined? Ractor 78 | end 79 | 80 | it "produces a case-normalized canonical string" do 81 | header = "Application/CloudEvents+JSON; charset=utf-8" 82 | content_type = CloudEvents::ContentType.new(header) 83 | assert_equal header.downcase, content_type.canonical_string 84 | assert Ractor.shareable?(content_type) if defined? Ractor 85 | end 86 | 87 | it "produces canonical string with spaces normalized" do 88 | header = "Application /CloudEvents+JSON ; charset=utf-8 " 89 | content_type = CloudEvents::ContentType.new(header) 90 | assert_equal "application/cloudevents+json; charset=utf-8", content_type.canonical_string 91 | assert Ractor.shareable?(content_type) if defined? Ractor 92 | end 93 | 94 | it "produces canonical string with quoted values" do 95 | header = "application/cloudevents+json; foo=\"utf-8 \"; bar=\"hi\" ;baz=\"hi\\\"\"" 96 | content_type = CloudEvents::ContentType.new(header) 97 | assert_equal "application/cloudevents+json; foo=\"utf-8 \"; bar=hi; baz=\"hi\\\"\"", content_type.canonical_string 98 | assert Ractor.shareable?(content_type) if defined? Ractor 99 | end 100 | 101 | it "drops comments" do 102 | header = "application/json (JSON rulz); ((oh btw) Ruby \\( rocks) charset=utf-8 (and so does unicode)(srsly)" 103 | content_type = CloudEvents::ContentType.new(header) 104 | assert_equal "application/json; charset=utf-8", content_type.canonical_string 105 | assert Ractor.shareable?(content_type) if defined? Ractor 106 | end 107 | 108 | it "uses the default in case of a parse error" do 109 | content_type = CloudEvents::ContentType.new("") 110 | assert_equal "text", content_type.media_type 111 | assert_equal "plain", content_type.subtype 112 | assert_equal "us-ascii", content_type.charset 113 | assert_equal "text/plain", content_type.canonical_string 114 | assert_equal "Failed to parse media type", content_type.error_message 115 | assert Ractor.shareable?(content_type) if defined? Ractor 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/test_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | 5 | describe CloudEvents::Event do 6 | let(:my_id) { "my_id" } 7 | let(:my_source) { "/my_source" } 8 | let(:my_type) { "my_type" } 9 | 10 | it "recognizes spec version 0" do 11 | event = CloudEvents::Event.create(id: my_id, 12 | source: my_source, 13 | type: my_type, 14 | spec_version: "0.3") 15 | assert_instance_of CloudEvents::Event::V0, event 16 | end 17 | 18 | it "recognizes spec version 1" do 19 | event = CloudEvents::Event.create(id: my_id, 20 | source: my_source, 21 | type: my_type, 22 | spec_version: "1.0") 23 | assert_instance_of CloudEvents::Event::V1, event 24 | end 25 | 26 | it "rejects spec version 2" do 27 | assert_raises CloudEvents::SpecVersionError do 28 | CloudEvents::Event.create(id: my_id, 29 | source: my_source, 30 | type: my_type, 31 | spec_version: "2.0") 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/test_examples.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | 5 | require "io/wait" 6 | 7 | require "toys/compat" 8 | require "toys/utils/exec" 9 | 10 | describe "examples" do 11 | let(:exec_util) { Toys::Utils::Exec.new } 12 | let(:examples_dir) { File.join(File.dirname(__dir__), "examples") } 13 | let(:client_dir) { File.join(examples_dir, "client") } 14 | let(:server_dir) { File.join(examples_dir, "server") } 15 | 16 | def expect_read(io, content, timeout) 17 | deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout 18 | received = String.new 19 | loop do 20 | time = Process.clock_gettime(Process::CLOCK_MONOTONIC) 21 | assert(time < deadline, "Did not receive content in time: #{content}") 22 | next unless io.wait_readable((deadline - time).ceil) 23 | received.concat(io.readpartial(1024)) 24 | return if received.include?(content) 25 | end 26 | end 27 | 28 | it "sends and receives an event" do 29 | skip if Toys::Compat.jruby? 30 | skip if Toys::Compat.truffleruby? 31 | skip if Toys::Compat.windows? 32 | Bundler.with_unbundled_env do 33 | assert(exec_util.exec(["bundle", "install"], out: :null, chdir: server_dir).success?, "server bundle failed") 34 | assert(exec_util.exec(["bundle", "install"], out: :null, chdir: client_dir).success?, "client bundle failed") 35 | exec_util.exec(["bundle", "exec", "ruby", "app.rb"], chdir: server_dir, 36 | in: :controller, out: :controller, err: :controller) do |server_control| 37 | expect_read(server_control.out, "* Listening on http", 5) 38 | client_result = exec_util.exec(["bundle", "exec", "ruby", "send.rb"], chdir: client_dir) 39 | assert(client_result.success?) 40 | expect_read(server_control.err, "Hello, CloudEvents!", 5) 41 | ensure 42 | server_control.kill("TERM") 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/test_http_binding.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | 5 | require "date" 6 | require "json" 7 | require "stringio" 8 | require "uri" 9 | 10 | describe CloudEvents::HttpBinding do 11 | let(:http_binding) { CloudEvents::HttpBinding.default } 12 | let(:minimal_http_binding) { CloudEvents::HttpBinding.new } 13 | let(:my_id) { "my_id" } 14 | let(:my_source_string) { "/my_source" } 15 | let(:my_source) { URI.parse(my_source_string) } 16 | let(:my_type) { "my_type" } 17 | let(:weird_type) { "¡Hola!\n\"100%\" 😀 " } 18 | let(:encoded_weird_type) { "%C2%A1Hola!%0A%22100%25%22%20%F0%9F%98%80%20" } 19 | let(:quoted_type) { "Hello Ruby world this\"is\\a1string okay" } 20 | let(:encoded_quoted_type) { "Hello%20\"Ruby%20world\"%20\"this\\\"is\\\\a\\1string\"%20okay" } 21 | let(:spec_version) { "1.0" } 22 | let(:my_simple_data) { "12345" } 23 | let(:my_json_escaped_simple_data) { '"12345"' } 24 | let(:my_content_type_string) { "text/plain; charset=us-ascii" } 25 | let(:my_content_type) { CloudEvents::ContentType.new(my_content_type_string) } 26 | let(:my_json_content_type_string) { "application/json; charset=us-ascii" } 27 | let(:my_json_content_type) { CloudEvents::ContentType.new(my_json_content_type_string) } 28 | let(:my_schema_string) { "/my_schema" } 29 | let(:my_schema) { URI.parse(my_schema_string) } 30 | let(:my_subject) { "my_subject" } 31 | let(:my_time_string) { "2020-01-12T20:52:05-08:00" } 32 | let(:my_time) { DateTime.rfc3339(my_time_string) } 33 | let(:my_trace_context) { "1234567890;9876543210" } 34 | let :my_json_struct do 35 | { 36 | "data" => my_simple_data, 37 | "datacontenttype" => my_content_type_string, 38 | "dataschema" => my_schema_string, 39 | "id" => my_id, 40 | "source" => my_source_string, 41 | "specversion" => spec_version, 42 | "subject" => my_subject, 43 | "time" => my_time_string, 44 | "type" => my_type, 45 | } 46 | end 47 | let(:my_json_struct_encoded) { JSON.dump(my_json_struct) } 48 | let(:my_json_batch_encoded) { JSON.dump([my_json_struct]) } 49 | let :my_json_data_struct do 50 | { 51 | "data" => my_simple_data, 52 | "datacontenttype" => my_json_content_type_string, 53 | "dataschema" => my_schema_string, 54 | "id" => my_id, 55 | "source" => my_source_string, 56 | "specversion" => spec_version, 57 | "subject" => my_subject, 58 | "time" => my_time_string, 59 | "type" => my_type, 60 | } 61 | end 62 | let(:my_json_data_struct_encoded) { JSON.dump(my_json_data_struct) } 63 | let :my_simple_binary_mode do 64 | { 65 | "rack.input" => StringIO.new(my_simple_data), 66 | "HTTP_CE_ID" => my_id, 67 | "HTTP_CE_SOURCE" => my_source_string, 68 | "HTTP_CE_TYPE" => my_type, 69 | "HTTP_CE_SPECVERSION" => spec_version, 70 | "CONTENT_TYPE" => my_content_type_string, 71 | "HTTP_CE_DATASCHEMA" => my_schema_string, 72 | "HTTP_CE_SUBJECT" => my_subject, 73 | "HTTP_CE_TIME" => my_time_string, 74 | } 75 | end 76 | let :my_json_binary_mode do 77 | { 78 | "rack.input" => StringIO.new(my_json_escaped_simple_data), 79 | "HTTP_CE_ID" => my_id, 80 | "HTTP_CE_SOURCE" => my_source_string, 81 | "HTTP_CE_TYPE" => my_type, 82 | "HTTP_CE_SPECVERSION" => spec_version, 83 | "CONTENT_TYPE" => my_json_content_type_string, 84 | "HTTP_CE_DATASCHEMA" => my_schema_string, 85 | "HTTP_CE_SUBJECT" => my_subject, 86 | "HTTP_CE_TIME" => my_time_string, 87 | } 88 | end 89 | let :my_minimal_binary_mode do 90 | { 91 | "rack.input" => StringIO.new(""), 92 | "HTTP_CE_ID" => my_id, 93 | "HTTP_CE_SOURCE" => my_source_string, 94 | "HTTP_CE_TYPE" => my_type, 95 | "HTTP_CE_SPECVERSION" => spec_version, 96 | } 97 | end 98 | let :my_extensions_binary_mode do 99 | { 100 | "rack.input" => StringIO.new(my_simple_data), 101 | "HTTP_CE_ID" => my_id, 102 | "HTTP_CE_SOURCE" => my_source_string, 103 | "HTTP_CE_TYPE" => my_type, 104 | "HTTP_CE_SPECVERSION" => spec_version, 105 | "CONTENT_TYPE" => my_content_type_string, 106 | "HTTP_CE_DATASCHEMA" => my_schema_string, 107 | "HTTP_CE_SUBJECT" => my_subject, 108 | "HTTP_CE_TIME" => my_time_string, 109 | "HTTP_CE_TRACECONTEXT" => my_trace_context, 110 | } 111 | end 112 | let :my_nonascii_binary_mode do 113 | { 114 | "rack.input" => StringIO.new(my_simple_data), 115 | "HTTP_CE_ID" => my_id, 116 | "HTTP_CE_SOURCE" => my_source_string, 117 | "HTTP_CE_TYPE" => encoded_weird_type, 118 | "HTTP_CE_SPECVERSION" => spec_version, 119 | "CONTENT_TYPE" => my_content_type_string, 120 | "HTTP_CE_DATASCHEMA" => my_schema_string, 121 | "HTTP_CE_SUBJECT" => my_subject, 122 | "HTTP_CE_TIME" => my_time_string, 123 | } 124 | end 125 | let :my_simple_event do 126 | CloudEvents::Event::V1.new(data_encoded: my_simple_data, 127 | data: my_simple_data, 128 | datacontenttype: my_content_type_string, 129 | dataschema: my_schema_string, 130 | id: my_id, 131 | source: my_source_string, 132 | specversion: spec_version, 133 | subject: my_subject, 134 | time: my_time_string, 135 | type: my_type) 136 | end 137 | let :my_json_event do 138 | CloudEvents::Event::V1.new(data_encoded: my_json_escaped_simple_data, 139 | data: my_simple_data, 140 | datacontenttype: my_json_content_type_string, 141 | dataschema: my_schema_string, 142 | id: my_id, 143 | source: my_source_string, 144 | specversion: spec_version, 145 | subject: my_subject, 146 | time: my_time_string, 147 | type: my_type) 148 | end 149 | let :my_minimal_event do 150 | CloudEvents::Event::V1.new(data_encoded: "", 151 | data: "", 152 | id: my_id, 153 | source: my_source_string, 154 | specversion: spec_version, 155 | type: my_type) 156 | end 157 | let :my_extensions_event do 158 | CloudEvents::Event::V1.new(data_encoded: my_simple_data, 159 | data: my_simple_data, 160 | datacontenttype: my_content_type_string, 161 | dataschema: my_schema_string, 162 | id: my_id, 163 | source: my_source_string, 164 | specversion: spec_version, 165 | subject: my_subject, 166 | time: my_time_string, 167 | tracecontext: my_trace_context, 168 | type: my_type) 169 | end 170 | let :my_nonascii_event do 171 | CloudEvents::Event::V1.new(data_encoded: my_simple_data, 172 | data: my_simple_data, 173 | datacontenttype: my_content_type_string, 174 | dataschema: my_schema_string, 175 | id: my_id, 176 | source: my_source_string, 177 | specversion: spec_version, 178 | subject: my_subject, 179 | time: my_time_string, 180 | type: weird_type) 181 | end 182 | 183 | def assert_request_matches(env, headers, body) 184 | env = env.dup 185 | assert_equal(env.delete("rack.input").read, body) 186 | headers_env = {} 187 | headers.each do |k, v| 188 | k = k.tr("-", "_").upcase 189 | k = "HTTP_#{k}" unless k == "CONTENT_TYPE" 190 | headers_env[k] = v 191 | end 192 | assert_equal(env, headers_env) 193 | end 194 | 195 | describe "percent_encode" do 196 | it "percent-encodes an ascii string" do 197 | str = http_binding.percent_encode(my_simple_data) 198 | assert_equal my_simple_data, str 199 | end 200 | 201 | it "percent-encodes a string with special characters" do 202 | str = http_binding.percent_encode(weird_type) 203 | assert_equal encoded_weird_type, str 204 | end 205 | end 206 | 207 | describe "percent_decode" do 208 | it "percent-decodes an ascii string" do 209 | str = http_binding.percent_decode(my_simple_data) 210 | assert_equal my_simple_data, str 211 | end 212 | 213 | it "percent-decodes a string with special characters" do 214 | str = http_binding.percent_decode(encoded_weird_type) 215 | assert_equal weird_type, str 216 | end 217 | 218 | it "percent-decodes a string with quoted tokens" do 219 | str = http_binding.percent_decode(encoded_quoted_type) 220 | assert_equal quoted_type, str 221 | end 222 | end 223 | 224 | describe "decode_event" do 225 | it "decodes a json-structured rack env with text content type" do 226 | env = { 227 | "rack.input" => StringIO.new(my_json_struct_encoded), 228 | "CONTENT_TYPE" => "application/cloudevents+json", 229 | } 230 | event = http_binding.decode_event(env) 231 | assert_equal my_simple_event, event 232 | end 233 | 234 | it "decodes a json-structured rack env with json content type" do 235 | env = { 236 | "rack.input" => StringIO.new(my_json_data_struct_encoded), 237 | "CONTENT_TYPE" => "application/cloudevents+json", 238 | } 239 | event = http_binding.decode_event(env) 240 | assert_equal my_json_event, event 241 | end 242 | 243 | it "decodes a json-batch rack env with text content type" do 244 | env = { 245 | "rack.input" => StringIO.new(my_json_batch_encoded), 246 | "CONTENT_TYPE" => "application/cloudevents-batch+json", 247 | } 248 | events = http_binding.decode_event(env) 249 | assert_equal [my_simple_event], events 250 | end 251 | 252 | it "decodes a binary mode rack env with text content type" do 253 | event = http_binding.decode_event(my_simple_binary_mode) 254 | assert_equal my_simple_event, event 255 | end 256 | 257 | it "decodes a binary mode rack env with json content type" do 258 | event = http_binding.decode_event(my_json_binary_mode) 259 | assert_equal my_json_event, event 260 | end 261 | 262 | it "decodes a binary mode rack env using an InputWrapper" do 263 | my_simple_binary_mode["rack.input"] = StringIO.new(my_simple_data) 264 | event = http_binding.decode_event(my_simple_binary_mode) 265 | assert_equal my_simple_event, event 266 | end 267 | 268 | it "decodes a binary mode rack env omitting optional headers" do 269 | event = http_binding.decode_event(my_minimal_binary_mode) 270 | assert_equal my_minimal_event, event 271 | end 272 | 273 | it "decodes a binary mode rack env with extension headers" do 274 | event = http_binding.decode_event(my_extensions_binary_mode) 275 | assert_equal my_extensions_event, event 276 | end 277 | 278 | it "decodes a binary mode rack env with non-ascii characters in a header" do 279 | event = http_binding.decode_event(my_nonascii_binary_mode) 280 | assert_equal my_nonascii_event, event 281 | end 282 | 283 | it "decodes a structured event using opaque" do 284 | env = { 285 | "rack.input" => StringIO.new(my_json_struct_encoded), 286 | "CONTENT_TYPE" => "application/cloudevents+json", 287 | } 288 | event = minimal_http_binding.decode_event(env, allow_opaque: true) 289 | assert_kind_of CloudEvents::Event::Opaque, event 290 | refute event.batch? 291 | assert_equal my_json_struct_encoded, event.content 292 | assert_equal CloudEvents::ContentType.new("application/cloudevents+json"), event.content_type 293 | end 294 | 295 | it "decodes a structured batch using opaque" do 296 | env = { 297 | "rack.input" => StringIO.new(my_json_batch_encoded), 298 | "CONTENT_TYPE" => "application/cloudevents-batch+json", 299 | } 300 | event = minimal_http_binding.decode_event(env, allow_opaque: true) 301 | assert_kind_of CloudEvents::Event::Opaque, event 302 | assert event.batch? 303 | assert_equal my_json_batch_encoded, event.content 304 | assert_equal CloudEvents::ContentType.new("application/cloudevents-batch+json"), event.content_type 305 | end 306 | 307 | it "raises UnsupportedFormatError when a format is not recognized" do 308 | env = { 309 | "rack.input" => StringIO.new(my_json_struct_encoded), 310 | "CONTENT_TYPE" => "application/cloudevents+hello", 311 | } 312 | assert_raises CloudEvents::UnsupportedFormatError do 313 | http_binding.decode_event(env) 314 | end 315 | end 316 | 317 | it "raises FormatSyntaxError when decoding malformed JSON event" do 318 | env = { 319 | "rack.input" => StringIO.new("!!!"), 320 | "CONTENT_TYPE" => "application/cloudevents+json", 321 | } 322 | error = assert_raises(CloudEvents::FormatSyntaxError) do 323 | http_binding.decode_event(env) 324 | end 325 | assert_kind_of JSON::ParserError, error.cause 326 | end 327 | 328 | it "raises FormatSyntaxError when decoding malformed JSON batch" do 329 | env = { 330 | "rack.input" => StringIO.new("!!!"), 331 | "CONTENT_TYPE" => "application/cloudevents-batch+json", 332 | } 333 | error = assert_raises(CloudEvents::FormatSyntaxError) do 334 | http_binding.decode_event(env) 335 | end 336 | assert_kind_of JSON::ParserError, error.cause 337 | end 338 | 339 | it "raises SpecVersionError when decoding a binary event with a bad specversion" do 340 | env = { 341 | "HTTP_CE_ID" => my_id, 342 | "HTTP_CE_SOURCE" => my_source_string, 343 | "HTTP_CE_TYPE" => my_type, 344 | "HTTP_CE_SPECVERSION" => "0.1", 345 | } 346 | assert_raises CloudEvents::SpecVersionError do 347 | http_binding.decode_event(env) 348 | end 349 | end 350 | 351 | it "raises NotCloudEventError when a content-type is not recognized" do 352 | env = { 353 | "rack.input" => StringIO.new(my_json_struct_encoded), 354 | "CONTENT_TYPE" => "application/json", 355 | } 356 | assert_raises CloudEvents::NotCloudEventError do 357 | http_binding.decode_event(env) 358 | end 359 | end 360 | 361 | it "raises NotCloudEventError when the method is GET" do 362 | env = { 363 | "REQUEST_METHOD" => "GET", 364 | "rack.input" => StringIO.new(my_json_struct_encoded), 365 | "CONTENT_TYPE" => "application/cloudevents+json", 366 | } 367 | assert_raises CloudEvents::NotCloudEventError do 368 | http_binding.decode_event(env) 369 | end 370 | end 371 | 372 | it "raises NotCloudEventError when the method is HEAD" do 373 | env = { 374 | "REQUEST_METHOD" => "HEAD", 375 | "rack.input" => StringIO.new(my_json_struct_encoded), 376 | "CONTENT_TYPE" => "application/cloudevents+json", 377 | } 378 | assert_raises CloudEvents::NotCloudEventError do 379 | http_binding.decode_event(env) 380 | end 381 | end 382 | end 383 | 384 | describe "encode_event" do 385 | it "encodes an event with text contenxt type to json-structured mode" do 386 | headers, body = http_binding.encode_event(my_simple_event, structured_format: true, sort: true) 387 | assert_equal({ "Content-Type" => "application/cloudevents+json; charset=utf-8" }, headers) 388 | assert_equal my_json_struct_encoded, body 389 | end 390 | 391 | it "encodes an event with json contenxt type to json-structured mode" do 392 | headers, body = http_binding.encode_event(my_json_event, structured_format: true, sort: true) 393 | assert_equal({ "Content-Type" => "application/cloudevents+json; charset=utf-8" }, headers) 394 | assert_equal my_json_data_struct_encoded, body 395 | end 396 | 397 | it "encodes a batch of events to json-structured mode" do 398 | headers, body = http_binding.encode_event([my_simple_event], structured_format: true, sort: true) 399 | assert_equal({ "Content-Type" => "application/cloudevents-batch+json; charset=utf-8" }, headers) 400 | assert_equal my_json_batch_encoded, body 401 | end 402 | 403 | it "encodes an event with text content type to binary mode" do 404 | headers, body = http_binding.encode_event(my_simple_event) 405 | assert_request_matches my_simple_binary_mode, headers, body 406 | end 407 | 408 | it "encodes an event with json content type to binary mode" do 409 | headers, body = http_binding.encode_event(my_json_event) 410 | assert_request_matches my_json_binary_mode, headers, body 411 | end 412 | 413 | it "encodes an event omitting optional attributes to binary mode" do 414 | headers, body = http_binding.encode_event(my_minimal_event) 415 | assert_request_matches my_minimal_binary_mode, headers, body 416 | end 417 | 418 | it "encodes an event with extension attributes to binary mode" do 419 | headers, body = http_binding.encode_event(my_extensions_event) 420 | assert_request_matches my_extensions_binary_mode, headers, body 421 | end 422 | 423 | it "encodes an event with non-ascii attribute characters to binary mode" do 424 | headers, body = http_binding.encode_event(my_nonascii_event) 425 | assert_request_matches my_nonascii_binary_mode, headers, body 426 | end 427 | 428 | it "decodes a structured event using opaque" do 429 | event = CloudEvents::Event::Opaque.new(my_json_struct_encoded, 430 | CloudEvents::ContentType.new("application/cloudevents+json")) 431 | headers, body = minimal_http_binding.encode_event(event) 432 | assert_equal({ "Content-Type" => "application/cloudevents+json" }, headers) 433 | assert_equal my_json_struct_encoded, body 434 | end 435 | 436 | it "decodes a structured batch using opaque" do 437 | event = CloudEvents::Event::Opaque.new(my_json_batch_encoded, 438 | CloudEvents::ContentType.new("application/cloudevents-batch+json")) 439 | headers, body = minimal_http_binding.encode_event(event) 440 | assert_equal({ "Content-Type" => "application/cloudevents-batch+json" }, headers) 441 | assert_equal my_json_batch_encoded, body 442 | end 443 | end 444 | 445 | describe "deprecated methods" do 446 | it "decodes a binary mode rack env with text content type" do 447 | event = http_binding.decode_rack_env(my_simple_binary_mode) 448 | expected_attributes = my_simple_event.to_h 449 | expected_attributes.delete("data_encoded") 450 | assert_equal expected_attributes, event.to_h 451 | end 452 | 453 | it "encodes an event with text contenxt type to binary mode" do 454 | headers, body = http_binding.encode_binary_content(my_simple_event, sort: true) 455 | assert_request_matches my_simple_binary_mode, headers, body 456 | end 457 | 458 | it "returns nil from the legacy decode method when a content-type is not recognized" do 459 | env = { 460 | "rack.input" => StringIO.new(my_json_struct_encoded), 461 | "CONTENT_TYPE" => "application/json", 462 | } 463 | assert_nil http_binding.decode_rack_env(env) 464 | end 465 | end 466 | 467 | describe "probable_event?" do 468 | it "detects a probable binary event" do 469 | env = { 470 | "HTTP_CE_SPECVERSION" => "1.0", 471 | } 472 | assert http_binding.probable_event?(env) 473 | end 474 | 475 | it "detects a probable structured event" do 476 | env = { 477 | "CONTENT_TYPE" => "application/cloudevents+myformat", 478 | } 479 | assert http_binding.probable_event?(env) 480 | end 481 | 482 | it "detects a probable batch event" do 483 | env = { 484 | "CONTENT_TYPE" => "application/cloudevents-batch+myformat", 485 | } 486 | assert http_binding.probable_event?(env) 487 | end 488 | 489 | it "detects a content type that is unlikely an event" do 490 | env = { 491 | "CONTENT_TYPE" => "application/json", 492 | } 493 | refute http_binding.probable_event?(env) 494 | end 495 | 496 | it "detects that an HTTP GET unlikely an event" do 497 | env = { 498 | "REQUEST_METHOD" => "GET", 499 | "HTTP_CE_SPECVERSION" => "1.0", 500 | } 501 | refute http_binding.probable_event?(env) 502 | end 503 | 504 | it "detects that an HTTP HEAD unlikely an event" do 505 | env = { 506 | "REQUEST_METHOD" => "HEAD", 507 | "HTTP_CE_SPECVERSION" => "1.0", 508 | } 509 | refute http_binding.probable_event?(env) 510 | end 511 | end 512 | end 513 | -------------------------------------------------------------------------------- /test/test_text_format.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | 5 | require "date" 6 | require "json" 7 | require "stringio" 8 | require "uri" 9 | 10 | describe CloudEvents::TextFormat do 11 | let(:text_format) { CloudEvents::TextFormat.new } 12 | let(:content) { '{"foo":"bar"}' } 13 | 14 | describe "decode_data" do 15 | [ 16 | "text/plain", 17 | "text/plain; charset=utf-8", 18 | "text/html", 19 | "application/octet-stream", 20 | ].each do |content_type| 21 | it "decodes content type #{content_type}" do 22 | content_type = CloudEvents::ContentType.new(content_type) 23 | result = text_format.decode_data(content: content, content_type: content_type) 24 | assert_equal content, result[:data] 25 | assert_equal content_type, result[:content_type] 26 | end 27 | end 28 | 29 | it "fails to decode content type application/json" do 30 | content_type = CloudEvents::ContentType.new("application/json") 31 | result = text_format.decode_data(content: content, content_type: content_type) 32 | assert_nil result 33 | end 34 | end 35 | 36 | describe "encode_data" do 37 | [ 38 | "text/plain", 39 | "text/plain; charset=utf-8", 40 | "text/html", 41 | "application/octet-stream", 42 | ].each do |content_type| 43 | it "encodes content type #{content_type}" do 44 | content_type = CloudEvents::ContentType.new(content_type) 45 | result = text_format.encode_data(data: content, content_type: content_type) 46 | assert_equal content, result[:content] 47 | assert_equal content_type, result[:content_type] 48 | end 49 | end 50 | 51 | it "fails to encode content type application/json" do 52 | content_type = CloudEvents::ContentType.new("application/json") 53 | result = text_format.encode_data(data: content, content_type: content_type) 54 | assert_nil result 55 | end 56 | end 57 | end 58 | --------------------------------------------------------------------------------