diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..b5da500 --- /dev/null +++ b/.clang-format @@ -0,0 +1,181 @@ +--- +Language: Cpp +AccessModifierOffset: -4 +AlignAfterOpenBracket: BlockIndent +AlignArrayOfStructures: None +AlignConsecutiveAssignments: + Enabled: true + AcrossEmptyLines: false + AcrossComments: true + AlignCompound: true + AlignFunctionPointers: false + PadOperators: true +AlignConsecutiveBitFields: + Enabled: true + AcrossEmptyLines: false + AcrossComments: true +AlignConsecutiveDeclarations: false +AlignConsecutiveMacros: AcrossComments +# AlignConsecutiveShortCaseStatements: false +AlignEscapedNewlines: Left +AlignOperands: Align +AlignTrailingComments: + Kind: Always + OverEmptyLines: 0 +AllowAllArgumentsOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowBreakBeforeNoexceptSpecifier: OnlyWithParen +AllowShortBlocksOnASingleLine: Empty +AllowShortCaseLabelsOnASingleLine: false +AllowShortCompoundRequirementOnASingleLine: true +AllowShortEnumsOnASingleLine: true +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: All +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterReturnType: None +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: true +BinPackParameters: true +BitFieldColonSpacing: Both +BreakBeforeBraces: Custom +BraceWrapping: + AfterCaseLabel: false + AfterClass: false + AfterControlStatement: MultiLine + AfterEnum: false + AfterExternBlock: false + AfterFunction: true + AfterNamespace: false + AfterStruct: false + AfterUnion: false + BeforeCatch: true + BeforeElse: true + BeforeLambdaBody: false + BeforeWhile: false + IndentBraces: false + SplitEmptyFunction: false + SplitEmptyRecord: false + SplitEmptyNamespace: false +# BracedInitializerIndentWidth: 4 +# BreakAdjacentStringLiterals: false +BreakAfterAttributes: Never +BreakArrays: false +BreakBeforeBinaryOperators: None +BreakBeforeConceptDeclarations: Always +BreakBeforeInlineASMColon: OnlyMultiline +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeColon +BreakInheritanceList: AfterComma +BreakStringLiterals: true +CommentPragmas: '^ NO(LINT|SONAR)' +CompactNamespaces: false +ColumnLimit: 120 +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: true +DisableFormat: false +EmptyLineAfterAccessModifier: Never +EmptyLineBeforeAccessModifier: LogicalBlock +FixNamespaceComments: true +ForEachMacros: + - foreach + - Q_FOREACH + - BOOST_FOREACH +IncludeBlocks: Preserve +IncludeCategories: + - Regex: '"stdafx\.h"' + Priority: -1 + SortPriority: 0 + CaseSensitive: false + - Regex: '^<[^.]+>' + Priority: 1 + SortPriority: 0 + CaseSensitive: false + - Regex: '^<.*\.h>' + Priority: 2 + SortPriority: 0 + CaseSensitive: false + - Regex: '^<.*' + Priority: 3 + SortPriority: 0 + CaseSensitive: false + - Regex: '.*' + Priority: 4 + SortPriority: 0 + CaseSensitive: false +IncludeIsMainRegex: '$' +IncludeIsMainSourceRegex: '' +IndentAccessModifiers: false +IndentCaseBlocks: false +IndentCaseLabels: true +IndentExternBlock: AfterExternBlock +IndentGotoLabels: true +IndentPPDirectives: AfterHash +IndentRequiresClause: false +IndentWidth: 4 +IndentWrappedFunctionNames: false +InsertBraces: false +InsertNewlineAtEOF: false +InsertTrailingCommas: None +IntegerLiteralSeparator: + Binary: 4 + BinaryMinDigits: 8 + Decimal: 3 + DecimalMinDigits: 7 + Hex: 4 + HexMinDigits: 8 +KeepEmptyLinesAtTheStartOfBlocks: false +LambdaBodyIndentation: Signature +LineEnding: DeriveLF +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +PPIndentWidth: -1 +PackConstructorInitializers: BinPack +PointerAlignment: Left +QualifierAlignment: Leave +ReferenceAlignment: Pointer +ReflowComments: true +RemoveBracesLLVM: false +RemoveSemicolon: false +RequiresClausePosition: OwnLine +RequiresExpressionIndentation: OuterScope +SeparateDefinitionBlocks: Leave +ShortNamespaceLines: 0 +SortIncludes: CaseSensitive +SortUsingDeclarations: Lexicographic +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: false +SpaceAroundPointerQualifiers: Default +SpaceBeforeAssignmentOperators: true +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +# SpaceBeforeJsonColon: false +SpaceBeforeParens: ControlStatementsExceptControlMacros +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: Never +SpacesInLineCommentPrefix: + Minimum: 1 + Maximum: -1 +# SpacesInParens: Never +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: Latest +StatementAttributeLikeMacros: + - Q_EMIT + - emit +StatementMacros: + - Q_UNUSED +TabWidth: 4 +UseTab: Never diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..403e6bb --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,17 @@ +--- +Checks: < + bugprone-*,cert-*,clang-analyzer-*,concurrency-*,cppcoreguidelines-*,hicpp-*,misc-*,modernize-*,performance-*,portability-*,readability-*, + + -bugprone-easily-swappable-parameters, + -cppcoreguidelines-avoid-do-while,-cppcoreguidelines-pro-type-union-access, + -misc-include-cleaner, + -modernize-use-trailing-return-type, + -readability-identifier-length,-readability-named-parameter, + + -bugprone-narrowing-conversions, + -cert-arr39-c,-cert-con36-c,-cert-con54-cpp,-cert-ctr56-cpp,-cert-dcl03-c,-cert-dcl16-c,-cert-dcl37-c,-cert-dcl51-cpp,-cert-dcl54-cpp,-cert-dcl59-cpp,-cert-err09-cpp,-cert-err61-cpp,-cert-exp42-c,-cert-fio38-c,-cert-flp37-c,-cert-int09-c,-cert-msc24-c,-cert-msc30-c,-cert-msc32-c,-cert-msc33-c,-cert-msc54-cpp,-cert-oop11-cpp,-cert-oop54-cpp,-cert-pos44-c,-cert-pos47-c,-cert-sig30-c,-cert-str34-c, + -cppcoreguidelines-avoid-c-arrays,-cppcoreguidelines-avoid-magic-numbers,-cppcoreguidelines-c-copy-assignment-signature,-cppcoreguidelines-explicit-virtual-functions,-cppcoreguidelines-macro-to-enum,-cppcoreguidelines-noexcept-destructor,-cppcoreguidelines-noexcept-move-operations,-cppcoreguidelines-noexcept-swap,-cppcoreguidelines-non-private-member-variables-in-classes,-cppcoreguidelines-use-default-member-init, + -hicpp-avoid-c-arrays,-hicpp-avoid-goto,-hicpp-braces-around-statements,-hicpp-deprecated-headers,-hicpp-explicit-conversions,-hicpp-function-size,-hicpp-invalid-access-moved,-hicpp-member-init,-hicpp-move-const-arg,-hicpp-named-parameter,-hicpp-new-delete-operators,-hicpp-no-array-decay,-hicpp-no-malloc,-hicpp-noexcept-move,-hicpp-special-member-functions,-hicpp-static-assert,-hicpp-undelegated-constructor,-hicpp-uppercase-literal-suffix,-hicpp-use-auto,-hicpp-use-emplace,-hicpp-use-equals-default,-hicpp-use-equals-delete,-hicpp-use-noexcept,-hicpp-use-nullptr,-hicpp-use-override,-hicpp-vararg + +FormatStyle: none +HeaderFilterRegex: '.*' diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml new file mode 100644 index 0000000..e90faee --- /dev/null +++ b/.github/actions/install-dependencies/action.yml @@ -0,0 +1,10 @@ +name: Set up dependencies +description: Set up dependencies for the project +runs: + using: composite + steps: + - name: Install dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libgmock-dev libgtest-dev nlohmann-json3-dev valgrind llvm gcovr diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..cd7a9c7 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>sjinks/.github:renovate-config" + ], + "git-submodules": { + "enabled": true, + "versioning": "loose" + }, + "customManagers": [ + { + "customType": "regex", + "fileMatch": ["^Dockerfile$"], + "matchStrings": [ + "# renovate: datasource=(?.*?) depName=(?.*?)( versioning=(?.*?))?( registryUrl=(?.*?))?\\sENV\\s+.*?_VERSION=(?.*)\\s" + ], + "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}" + }, + { + "customType": "regex", + "fileMatch": ["(^|/)\\.github/(?:workflows|actions)/.+\\.ya?ml$"], + "matchStrings": [ + "# renovate: datasource=(?.*?) depName=(?.*?)( versioning=(?.*?))?( registryUrl=(?.*?))?\\s+.*?_VERSION:\\s*(?.*)\\s" + ], + "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}" + } + ] +} diff --git a/.github/workflows/ci-vcpkg.yml b/.github/workflows/ci-vcpkg.yml new file mode 100644 index 0000000..e6223e4 --- /dev/null +++ b/.github/workflows/ci-vcpkg.yml @@ -0,0 +1,61 @@ +name: Build and Test (vcpkg) + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build and Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + triplet: x64-windows-release + - os: macos-latest + triplet: arm64-osx-release + - os: ubuntu-latest + triplet: x64-linux-release + runs-on: ${{ matrix.os }} + permissions: + contents: read + env: + VCPKG_DEFAULT_TRIPLET: ${{ matrix.triplet }} + VCPKG_DEFAULT_HOST_TRIPLET: ${{ matrix.triplet }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + egress-policy: audit + + - name: Check out code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: true + + - name: Set up cmake and ninja + uses: lukka/get-cmake@b516803a3c5fac40e2e922349d15cdebdba01e60 # v3.30.5 + + - name: Set up vcpkg + uses: lukka/run-vcpkg@5e0cab206a5ea620130caf672fce3e4a6b5666a1 # v11.5 + + - name: Build and test + run: | + cmake --preset debug-vcpkg + cmake --build --preset debug-vcpkg + ctest --preset debug-vcpkg + + - name: Install + run: sudo cmake --install build-debug-vcpkg + if: runner.os != 'Windows' + + - name: Install (Windows) + run: cmake --install build-debug-vcpkg --config Debug + if: runner.os == 'Windows' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3cea5b1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: Build and Test + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + prepare: + name: Prepare list of configurations + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + presets: ${{ steps.set-matrix.outputs.presets }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + github.com:443 + + - name: Check out the source code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set matrix + id: set-matrix + run: echo presets="$(jq '.configurePresets[] | select(.hidden == false) | {name, description}' CMakePresets.json | jq --slurp -c .)" >> "${GITHUB_OUTPUT}" + + build: + needs: prepare + name: Build and Test (${{ matrix.preset.description }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + preset: ${{ fromJson(needs.prepare.outputs.presets) }} + permissions: + contents: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + azure.archive.ubuntu.com:80 + esm.ubuntu.com:443 + github.com:443 + motd.ubuntu.com:443 + objects.githubusercontent.com:443 + packages.microsoft.com:443 + + - name: Check out code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: true + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + + - name: Build and test + run: | + cmake --preset ${{ matrix.preset.name }} + cmake --build --preset ${{ matrix.preset.name }} + ctest --preset ${{ matrix.preset.name }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..31b4b1f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,60 @@ +name: CodeQL + +on: + push: + branches: + - master + pull_request: + branches: + - master + schedule: + - cron: '7 11 * * 4' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-22.04 + timeout-minutes: 60 + permissions: + security-events: write + actions: read + contents: read + strategy: + fail-fast: false + matrix: + language: + - c-cpp + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + azure.archive.ubuntu.com:80 + esm.ubuntu.com:443 + github.com:443 + motd.ubuntu.com:443 + objects.githubusercontent.com:443 + packages.microsoft.com:443 + + - name: Check out code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0 + with: + languages: ${{ matrix.language }} + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + + - name: Build + run: | + cmake -B build + cmake --build build + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/ctest.yml b/.github/workflows/ctest.yml new file mode 100644 index 0000000..53fe87a --- /dev/null +++ b/.github/workflows/ctest.yml @@ -0,0 +1,68 @@ +name: Test + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + prepare: + name: Prepare list of configurations + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + scripts: ${{ steps.set-matrix.outputs.scripts }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + github.com:443 + + - name: Check out the source code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set matrix + id: set-matrix + run: echo scripts="$(for i in ci/*.ctest.cmake; do echo '"'$(basename $i .ctest.cmake)'"'; done | jq --slurp -c)" >> "${GITHUB_OUTPUT}" + + test: + needs: prepare + name: Test (${{ matrix.script }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + script: ${{ fromJson(needs.prepare.outputs.scripts) }} + permissions: + contents: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + azure.archive.ubuntu.com:80 + esm.ubuntu.com:443 + github.com:443 + motd.ubuntu.com:443 + objects.githubusercontent.com:443 + packages.microsoft.com:443 + + - name: Check out code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + + - name: Run tests + run: ctest -V -S "ci/${{ matrix.script }}.ctest.cmake" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..a8469e3 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,43 @@ +name: Lint C++ code + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +permissions: + contents: none + +jobs: + lint: + name: clang-format (${{ matrix.path }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + path: + - src + - test + permissions: + contents: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + ghcr.io:443 + github.com:443 + pkg-containers.githubusercontent.com:443 + + - name: Check out code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: clang-format Check + uses: jidicula/clang-format-action@c74383674bf5f7c69f60ce562019c1c94bc1421a # v4.13.0 + with: + clang-format-version: 18 + check-path: ${{ matrix.path }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45b4ead --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.cache/ +/.vscode/ +/build/ +/build-*/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..84b40af --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "vcpkg"] + path = vcpkg + url = https://github.com/microsoft/vcpkg.git + branch = 2024.10.21 diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..c4ef316 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,224 @@ +cmake_minimum_required(VERSION 3.25) + +set(EXPORT_COMPILE_COMMANDS ON) + +option(BUILD_SHARED_LIBS "Build shared libraries" OFF) +option(BUILD_TESTS "Build tests" ON) +option(ENABLE_MAINTAINER_MODE "Enable maintainer mode" OFF) + +project( + wwa_jsonrpc + VERSION 1.0.0 + DESCRIPTION "JSON-RPC 2.0 library" + HOMEPAGE_URL "https://github.com/sjinks/jsonrpc-cpp" + LANGUAGES CXX +) + +if(CMAKE_CONFIGURATION_TYPES) + list(APPEND CMAKE_CONFIGURATION_TYPES "Coverage" "ASAN" "LSAN" "UBSAN") +endif() + +set(CMAKE_VERBOSE_MAKEFILE ON) + +string(TOLOWER "${CMAKE_BUILD_TYPE}" CMAKE_BUILD_TYPE_LOWER) +string(TOLOWER "${CMAKE_CONFIGURATION_TYPES}" CMAKE_CONFIGURATION_TYPES_LOWER) + +string(REGEX MATCH "Clang" CMAKE_COMPILER_IS_CLANG "${CMAKE_CXX_COMPILER_ID}") +string(REGEX MATCH "GNU" CMAKE_COMPILER_IS_GNU "${CMAKE_CXX_COMPILER_ID}") + +if(BUILD_TESTS) + include(FindGTest) + find_package(GTest CONFIG COMPONENTS gtest gmock) + if(NOT TARGET GTest::gtest OR NOT TARGET GTest::gmock) + message(WARNING "GTest not found, tests will not be built") + set(BUILD_TESTS OFF) + endif() +endif() + +if(CMAKE_COMPILER_IS_GNU OR CMAKE_COMPILER_IS_CLANG) + set(CMAKE_CXX_FLAGS_ASAN "-O1 -g -fsanitize=address -fno-omit-frame-pointer -fno-optimize-sibling-calls") + set(CMAKE_CXX_FLAGS_LSAN "-O1 -g -fsanitize=leak -fno-omit-frame-pointer -fno-optimize-sibling-calls") + + if(CMAKE_COMPILER_IS_GNU) + set(CMAKE_CXX_FLAGS_COVERAGE "-Og -g --coverage -fprofile-abs-path") + set(CMAKE_CXX_FLAGS_UBSAN "-O1 -g -fsanitize=undefined -fsanitize=float-divide-by-zero -fno-omit-frame-pointer") + elseif(CMAKE_COMPILER_IS_CLANG) + set(CMAKE_CXX_FLAGS_COVERAGE "-O1 -g --coverage") + set(CMAKE_CXX_FLAGS_UBSAN "-O1 -g -fsanitize=undefined -fsanitize=float-divide-by-zero -fsanitize=integer -fsanitize=implicit-conversion -fsanitize=local-bounds -fsanitize=nullability -fno-omit-frame-pointer") + endif() +endif() + +if(ENABLE_MAINTAINER_MODE) + add_compile_options( + "$<$:-Weverything;-Wno-c++98-compat;-Wno-c++98-compat-pedantic;-Wno-pre-c++17-compat;-Wno-c++20-compat;-Wno-padded;-Werror>" + "$<$:-Wall;-Wextra;-Werror;-pedantic>" + ) +endif() + +find_package(Threads REQUIRED) +find_package(nlohmann_json REQUIRED) + +add_library(${PROJECT_NAME}) +target_sources( + ${PROJECT_NAME} + PRIVATE + src/exception.cpp + src/dispatcher.cpp + src/dispatcher_p.cpp + PUBLIC + FILE_SET HEADERS + TYPE HEADERS + BASE_DIRS src + FILES + src/dispatcher.h + src/exception.h + src/export.h + src/traits.h +) + +set_target_properties( + ${PROJECT_NAME} + PROPERTIES + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON + CXX_STANDARD 20 + CXX_STANDARD_REQUIRED YES + CXX_EXTENSIONS NO + INTERFACE_COMPILE_FEATURES cxx_std_20 + POSITION_INDEPENDENT_CODE ON + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR} +) + +target_link_libraries( + ${PROJECT_NAME} + PUBLIC nlohmann_json::nlohmann_json + PRIVATE Threads::Threads +) + +target_include_directories( + ${PROJECT_NAME} + PUBLIC + $ + $ +) + +if(NOT BUILD_SHARED_LIBS) + target_compile_definitions(${PROJECT_NAME} PUBLIC WWA_JSONRPC_STATIC_DEFINE) +endif() + +if(BUILD_TESTS) + include(CTest) + enable_testing() + add_subdirectory(test) +endif() + +find_program(CLANG_FORMAT NAMES clang-format) +find_program(CLANG_TIDY NAMES clang-tidy) + +if(CLANG_FORMAT OR CLANG_TIDY) + file(GLOB_RECURSE ALL_SOURCE_FILES RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} LIST_DIRECTORIES OFF src/*.cpp test/*.cpp) + file(GLOB_RECURSE ALL_HEADER_FILES RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} LIST_DIRECTORIES OFF src/*.h test/*.h) + + if(CLANG_FORMAT) + add_custom_target( + format + COMMAND ${CLANG_FORMAT} --Wno-error=unknown -i -style=file ${ALL_SOURCE_FILES} ${ALL_HEADER_FILES} + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + ) + endif() + + if(CLANG_TIDY) + add_custom_target( + tidy + COMMAND ${CLANG_TIDY} -p ${CMAKE_BINARY_DIR} ${ALL_SOURCE_FILES} + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + ) + endif() +endif() + +set(ENABLE_COVERAGE OFF) +if("coverage" IN_LIST CMAKE_CONFIGURATION_TYPES_LOWER OR "coverage" STREQUAL CMAKE_BUILD_TYPE_LOWER) + if(CMAKE_COMPILER_IS_GNU OR CMAKE_COMPILER_IS_CLANG) + find_program(GCOVR gcovr) + if (GCOVR) + if(CMAKE_COMPILER_IS_GNU) + find_program(GCOV gcov) + set(GCOV_TOOL_NAME gcov) + set(GCOV_TOOL gcov) + elseif(CMAKE_COMPILER_IS_CLANG) + find_program(GCOV llvm-cov) + set(GCOV_TOOL_NAME llvm-cov) + set(GCOV_TOOL llvm-cov gcov) + endif() + + if(GCOV) + set(ENABLE_COVERAGE ON) + else() + message(WARNING "${GCOV_TOOL_NAME} not found, coverage report will not be generated") + endif() + else() + message(WARNING "gcovr not found, coverage report will not be generated") + endif() + endif() +endif() + +if(ENABLE_COVERAGE) + add_custom_target( + coverage + COMMAND ${CMAKE_COMMAND} -E rm -rf "${PROJECT_BINARY_DIR}/coverage" + COMMAND ${CMAKE_COMMAND} -E make_directory "${PROJECT_BINARY_DIR}/coverage" + COMMAND ${CMAKE_CTEST_COMMAND} -C $ -T test --output-on-failure + COMMAND + gcovr -f "${PROJECT_SOURCE_DIR}/src/" -r "${PROJECT_SOURCE_DIR}" + --html-details -o "${PROJECT_BINARY_DIR}/coverage/index.html" + --exclude-noncode-lines --exclude-throw-branches --exclude-unreachable-branches --decisions + --gcov-executable="${GCOV_TOOL}" + --print-summary + WORKING_DIRECTORY ${PROJECT_BINARY_DIR} + USES_TERMINAL + ) + add_dependencies(coverage test_jsonrpc) +endif() + +include(CMakePackageConfigHelpers) +include(GNUInstallDirs) +install( + TARGETS ${PROJECT_NAME} + EXPORT ${PROJECT_NAME}-target + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + FILE_SET HEADERS DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/${PROJECT_NAME}" +) + +install( + EXPORT ${PROJECT_NAME}-target + FILE ${PROJECT_NAME}-target.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME} +) + +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}-config-version.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY AnyNewerVersion +) + +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/${PROJECT_NAME}-config.cmake + ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}-config.cmake + COPYONLY +) + +install( + FILES + ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}-config.cmake + ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}-config-version.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME} +) + +configure_file("${CMAKE_SOURCE_DIR}/cmake/pkg-config.pc.in" "${PROJECT_NAME}.pc" @ONLY) +install( + FILES "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}.pc" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/pkgconfig" +) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..1a55a5c --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,229 @@ +{ + "version": 4, + "cmakeMinimumRequired": { + "major": 3, + "minor": 23, + "patch": 0 + }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "binaryDir": "${sourceDir}/build-${presetName}", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_EXPORT_COMPILE_COMMANDS": "YES", + "BUILD_TESTS": "ON" + } + }, + { + "name": "base-coverage", + "hidden": true, + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Coverage" + } + }, + { + "name": "base-vcpkg", + "hidden": true, + "inherits": "base", + "toolchainFile": "${fileDir}/vcpkg/scripts/buildsystems/vcpkg.cmake" + }, + { + "name": "default", + "description": "Default build", + "inherits": "base", + "hidden": false, + "binaryDir": "${sourceDir}/build" + }, + { + "name": "debug", + "description": "Debug build", + "inherits": "base", + "hidden": false + }, + { + "name": "release", + "description": "Release build", + "inherits": "base", + "hidden": false, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "coverage-clang", + "description": "Coverage build with clang", + "inherits": "base-coverage", + "hidden": false, + "cacheVariables": { + "CMAKE_CXX_COMPILER": "clang++" + } + }, + { + "name": "coverage-gcc", + "description": "Coverage build with gcc", + "inherits": "base-coverage", + "hidden": false, + "cacheVariables": { + "CMAKE_CXX_COMPILER": "g++" + } + }, + { + "name": "mm-clang", + "description": "Maintainer mode build with clang", + "inherits": "base", + "hidden": false, + "cacheVariables": { + "ENABLE_MAINTAINER_MODE": "ON", + "CMAKE_CXX_COMPILER": "clang++" + } + }, + { + "name": "mm-gcc", + "description": "Maintainer mode build with gcc", + "inherits": "base", + "hidden": false, + "cacheVariables": { + "ENABLE_MAINTAINER_MODE": "ON", + "CMAKE_CXX_COMPILER": "g++" + } + }, + { + "name": "debug-vcpkg", + "description": "Debug + vcpkg", + "inherits": "base-vcpkg", + "hidden": false + } + ], + "buildPresets": [ + { + "name": "base", + "hidden": true, + "verbose": true + }, + { + "name": "default", + "description": "Default build", + "inherits": "base", + "hidden": false, + "configurePreset": "default" + }, + { + "name": "debug", + "description": "Debug build", + "inherits": "base", + "hidden": false, + "configurePreset": "debug" + }, + { + "name": "release", + "description": "Release build", + "inherits": "base", + "hidden": false, + "configurePreset": "release" + }, + { + "name": "coverage-clang", + "description": "Coverage build with clang", + "inherits": "base", + "hidden": false, + "configurePreset": "coverage-clang" + }, + { + "name": "coverage-gcc", + "description": "Coverage build with gcc", + "inherits": "base", + "hidden": false, + "configurePreset": "coverage-gcc" + }, + { + "name": "mm-clang", + "description": "Maintainer mode build with clang", + "inherits": "base", + "hidden": false, + "configurePreset": "mm-clang" + }, + { + "name": "mm-gcc", + "description": "Maintainer mode build with gcc", + "inherits": "base", + "hidden": false, + "configurePreset": "mm-gcc" + }, + { + "name": "debug-vcpkg", + "description": "Debug + vcpkg", + "inherits": "base", + "hidden": false, + "configurePreset": "debug-vcpkg" + } + ], + "testPresets": [ + { + "name": "base", + "hidden": true, + "output": { + "outputOnFailure": true, + "quiet": false + } + }, + { + "name": "default", + "description": "Default build", + "inherits": "base", + "hidden": false, + "configurePreset": "default" + }, + { + "name": "debug", + "description": "Debug build", + "inherits": "base", + "hidden": false, + "configurePreset": "debug" + }, + { + "name": "release", + "description": "Release build", + "inherits": "base", + "hidden": false, + "configurePreset": "release" + }, + { + "name": "coverage-clang", + "description": "Coverage build with clang", + "inherits": "base", + "hidden": false, + "configurePreset": "coverage-clang" + }, + { + "name": "coverage-gcc", + "description": "Coverage build with gcc", + "inherits": "base", + "hidden": false, + "configurePreset": "coverage-gcc" + }, + { + "name": "mm-clang", + "description": "Maintainer mode build with clang", + "inherits": "base", + "hidden": false, + "configurePreset": "mm-clang" + }, + { + "name": "mm-gcc", + "description": "Maintainer mode build with gcc", + "inherits": "base", + "hidden": false, + "configurePreset": "mm-gcc" + }, + { + "name": "debug-vcpkg", + "description": "Debug + vcpkg", + "inherits": "base", + "hidden": false, + "configurePreset": "debug-vcpkg" + } + ] +} diff --git a/ci/asan.ctest.cmake b/ci/asan.ctest.cmake new file mode 100644 index 0000000..aeb3e39 --- /dev/null +++ b/ci/asan.ctest.cmake @@ -0,0 +1,10 @@ +include("${CMAKE_CURRENT_LIST_DIR}/common.cmake") + +set(CTEST_CONFIGURATION_TYPE "ASAN") +set(CTEST_MEMORYCHECK_TYPE "AddressSanitizer") + +ctest_start(Experimental) +set(options -DCMAKE_CXX_COMPILER=clang++) +ctest_configure(OPTIONS "${options}") +ctest_build() +ctest_memcheck() diff --git a/ci/common.cmake b/ci/common.cmake new file mode 100644 index 0000000..2a444ef --- /dev/null +++ b/ci/common.cmake @@ -0,0 +1,23 @@ +cmake_host_system_information(RESULT HOSTNAME QUERY HOSTNAME) +set(CTEST_SITE "${HOSTNAME}") + +cmake_path(GET CMAKE_PARENT_LIST_FILE STEM FNAME) + +set(CTEST_SOURCE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") +set(CTEST_BINARY_DIRECTORY "${CTEST_SOURCE_DIRECTORY}/build-ci-${FNAME}") + +find_program(CTEST_GIT_COMMAND git) +if(CTEST_GIT_COMMAND) + execute_process( + COMMAND ${CTEST_GIT_COMMAND} rev-parse HEAD + OUTPUT_VARIABLE GIT_COMMIT + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + string(SUBSTRING ${GIT_COMMIT} 0 7 COMMIT_ID) + set(CTEST_BUILD_NAME "${COMMIT_ID}-${CMAKE_HOST_SYSTEM_NAME}-${CMAKE_HOST_SYSTEM_PROCESSOR}-${FNAME}") +else() + set(CTEST_BUILD_NAME "${CMAKE_HOST_SYSTEM_NAME}-${CMAKE_HOST_SYSTEM_PROCESSOR}-${FNAME}") +endif() + +set(CTEST_CMAKE_GENERATOR "Unix Makefiles") diff --git a/ci/lsan.ctest.cmake b/ci/lsan.ctest.cmake new file mode 100644 index 0000000..98cbdad --- /dev/null +++ b/ci/lsan.ctest.cmake @@ -0,0 +1,10 @@ +include("${CMAKE_CURRENT_LIST_DIR}/common.cmake") + +set(CTEST_CONFIGURATION_TYPE "LSAN") +set(CTEST_MEMORYCHECK_TYPE "LeakSanitizer") + +ctest_start(Experimental) +set(options -DCMAKE_CXX_COMPILER=clang++) +ctest_configure(OPTIONS "${options}") +ctest_build() +ctest_memcheck() diff --git a/ci/ubsan.ctest.cmake b/ci/ubsan.ctest.cmake new file mode 100644 index 0000000..fddfdec --- /dev/null +++ b/ci/ubsan.ctest.cmake @@ -0,0 +1,10 @@ +include("${CMAKE_CURRENT_LIST_DIR}/common.cmake") + +set(CTEST_CONFIGURATION_TYPE "UBSAN") +set(CTEST_MEMORYCHECK_TYPE "UndefinedBehaviorSanitizer") + +ctest_start(Experimental) +set(options -DCMAKE_CXX_COMPILER=clang++) +ctest_configure(OPTIONS "${options}") +ctest_build() +ctest_memcheck() diff --git a/ci/valgrind.ctest.cmake b/ci/valgrind.ctest.cmake new file mode 100644 index 0000000..94e71ca --- /dev/null +++ b/ci/valgrind.ctest.cmake @@ -0,0 +1,17 @@ +include("${CMAKE_CURRENT_LIST_DIR}/common.cmake") + +set(CTEST_CONFIGURATION_TYPE "Debug") + +find_program(CTEST_MEMORYCHECK_COMMAND valgrind) +set(CTEST_MEMORYCHECK_COMMAND_OPTIONS "--error-exitcode=1 -q --track-origins=yes --leak-check=yes --show-reachable=yes --num-callers=50") +set(CTEST_MEMORYCHECK_TYPE "Valgrind") + +ctest_start(Experimental) +if(CTEST_MEMORYCHECK_COMMAND) + set(options -DCMAKE_CXX_COMPILER=clang++) + ctest_configure(OPTIONS "${options}") + ctest_build() + ctest_memcheck() +else() + message(WARNING "valgrind command not found, skipping check") +endif() diff --git a/cmake/pkg-config.pc.in b/cmake/pkg-config.pc.in new file mode 100644 index 0000000..56e3ddf --- /dev/null +++ b/cmake/pkg-config.pc.in @@ -0,0 +1,8 @@ +includedir=@CMAKE_INSTALL_PREFIX@/@CMAKE_INSTALL_INCLUDEDIR@ +libdir=@CMAKE_INSTALL_PREFIX@/@CMAKE_INSTALL_LIBDIR@ + +Name: @PROJECT_NAME@ +Description: @PROJECT_DESCRIPTION@ +Version: @PROJECT_VERSION@ +Cflags: -I${includedir} -std=c++20 +Libs: -L${libdir} -l@PROJECT_NAME@ diff --git a/cmake/wwa_jsonrpc-config.cmake b/cmake/wwa_jsonrpc-config.cmake new file mode 100644 index 0000000..48fa744 --- /dev/null +++ b/cmake/wwa_jsonrpc-config.cmake @@ -0,0 +1,11 @@ +get_filename_component(JSONRPC_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" PATH) + +list(APPEND CMAKE_MODULE_PATH ${JSONRPC_CMAKE_DIR}) + +include(CMakeFindDependencyMacro) +find_dependency(nlohmann-json) + +if(NOT TARGET wwa_jsonrpc) + include("${JSONRPC_CMAKE_DIR}/wwa_jsonrpc-target.cmake") + add_library(wwa::jsonrpc ALIAS wwa_jsonrpc) +endif() diff --git a/src/dispatcher.cpp b/src/dispatcher.cpp new file mode 100644 index 0000000..cf24a08 --- /dev/null +++ b/src/dispatcher.cpp @@ -0,0 +1,56 @@ +#include "dispatcher.h" +#include "dispatcher_p.h" +#include "exception.h" + +namespace wwa::json_rpc { + +dispatcher::dispatcher() : d_ptr(std::make_unique(this)) {} + +dispatcher::~dispatcher() = default; + +void dispatcher::add_internal_method(std::string_view method, handler_t&& handler) +{ + this->d_ptr->add_handler(method, std::move(handler)); +} + +std::string dispatcher::parse_and_process_request(const std::string& r) +{ + nlohmann::json request; + try { + request = nlohmann::json::parse(r); + } + catch (const nlohmann::json::exception& e) { + this->on_request(); + const auto json = dispatcher_private::generate_error_response( + exception(exception::PARSE_ERROR, e.what()), nlohmann::json(nullptr) + ); + + this->on_request_processed({}, exception::PARSE_ERROR); + return json.dump(); + } + + return this->process_request(request); +} + +std::string dispatcher::process_request(const nlohmann::json& request) +{ + const auto json = this->d_ptr->process_request(request); + return json.is_discarded() ? std::string{} : json.dump(); +} + +void dispatcher::on_request() +{ + // Do nothing +} + +void dispatcher::on_method(const std::string&) +{ + // Do nothing +} + +void dispatcher::on_request_processed(const std::string&, int) +{ + // Do nothing +} + +} // namespace wwa::json_rpc diff --git a/src/dispatcher.h b/src/dispatcher.h new file mode 100644 index 0000000..63fc7eb --- /dev/null +++ b/src/dispatcher.h @@ -0,0 +1,137 @@ +#ifndef FAB131EA_3F90_43B6_833D_EB89DA373735 +#define FAB131EA_3F90_43B6_833D_EB89DA373735 + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "exception.h" +#include "export.h" +#include "traits.h" + +namespace wwa::json_rpc { + +class dispatcher_private; +class WWA_JSONRPC_EXPORT dispatcher { +private: + friend class dispatcher_private; + using handler_t = std::function; + +public: + dispatcher(); + virtual ~dispatcher(); + + dispatcher(const dispatcher&) = delete; + dispatcher& operator=(const dispatcher&) = delete; + dispatcher(dispatcher&&) = default; + dispatcher& operator=(dispatcher&&) = default; + + template + void add(std::string_view method, F&& f) + { + this->add(method, std::forward(f), nullptr); + } + + template + void add(std::string_view method, F&& f, C instance) + { + using traits = details::function_traits>; + using ArgsTuple = typename traits::args_tuple; + constexpr auto args_size = std::tuple_size(); + + auto&& closure = + this->create_closure(instance, std::forward(f), std::make_index_sequence{}); + this->add_internal_method(method, std::forward(closure)); + } + + std::string parse_and_process_request(const std::string& r); + std::string process_request(const nlohmann::json& request); + + virtual void on_request(); + virtual void on_method(const std::string& method); + virtual void on_request_processed(const std::string& method, int code); + +private: + std::unique_ptr d_ptr; + + void add_internal_method(std::string_view method, handler_t&& handler); + + template + nlohmann::json invoke_function(F&& f, Tuple&& tuple) const + { + using ReturnType = typename details::function_traits>::return_type; + + if constexpr (std::is_void_v) { + std::apply(std::forward(f), std::forward(tuple)); + // NOLINTNEXTLINE(modernize-return-braced-init-list) -- braced init will create a JSON array + return nlohmann::json(nullptr); + } + else { + return std::apply(std::forward(f), std::forward(tuple)); + } + } + + template + constexpr auto + create_closure(C inst, F&& f, std::index_sequence) // NOLINT(readability-function-cognitive-complexity) + { + static_assert(std::is_pointer_v || std::is_null_pointer_v); + return [func = std::forward(f), inst, this](const nlohmann::json& params) { + constexpr auto args_size = std::tuple_size(); + + if (params.is_array()) { + if constexpr (args_size == 1) { + if constexpr (std::is_same_v>, nlohmann::json>) { + auto&& tuple_args = [inst, ¶ms]() constexpr { + (void)inst; + if constexpr (std::is_null_pointer_v) { + return std::make_tuple(params); + } + else { + return std::make_tuple(inst, params); + } + }(); + + return this->invoke_function(func, std::forward(tuple_args)); + } + } + + if (params.size() == std::tuple_size()) { + auto&& tuple_args = [inst, ¶ms]() constexpr { + (void)inst; + (void)params; + try { + if constexpr (std::is_null_pointer_v) { + return std::make_tuple( + params[Indices].get>>()... + ); + } + else { + return std::make_tuple( + inst, params[Indices].get>>()... + ); + } + } + catch (const nlohmann::json::exception& e) { + throw exception(exception::INVALID_PARAMS, e.what()); + } + }(); + + return this->invoke_function(func, std::forward(tuple_args)); + } + } + + throw exception(exception::INVALID_PARAMS, err_invalid_params_passed_to_method); + }; + } +}; + +} // namespace wwa::json_rpc + +#endif /* FAB131EA_3F90_43B6_833D_EB89DA373735 */ diff --git a/src/dispatcher_p.cpp b/src/dispatcher_p.cpp new file mode 100644 index 0000000..448ab93 --- /dev/null +++ b/src/dispatcher_p.cpp @@ -0,0 +1,173 @@ +#include "dispatcher_p.h" + +#include +#include + +#include "dispatcher.h" +#include "exception.h" + +namespace { + +using shared_lock = std::unique_lock; +using unique_lock = std::unique_lock; + +bool is_valid_request_id(const nlohmann::json& id) +{ + return id.is_string() || id.is_number() || id.is_null() || id.is_discarded(); +} + +} // namespace + +namespace wwa::json_rpc { + +// NOLINTNEXTLINE(misc-use-anonymous-namespace) -- cannot move to an anonymous namespace because of ADL +static void from_json(const nlohmann::json& j, jsonrpc_request& r) +{ + r.params = nlohmann::json(nlohmann::json::value_t::discarded); + r.id = nlohmann::json(nlohmann::json::value_t::discarded); + + j.at("jsonrpc").get_to(r.jsonrpc); + j.at("method").get_to(r.method); + + if (j.contains("params")) { + j.at("params").get_to(r.params); + } + + if (j.contains("id")) { + j.at("id").get_to(r.id); + } + + if (r.params.is_discarded()) { + r.params = nlohmann::json::array(); + } + else if (r.params.is_object()) { + r.params = nlohmann::json::array({r.params}); + } +} + +void dispatcher_private::add_handler(std::string_view method, dispatcher::handler_t&& handler) +{ + const unique_lock lock(this->m_methods_mutex); + this->m_methods.try_emplace(std::string(method), std::move(handler)); +} + +jsonrpc_request dispatcher_private::parse_request(const nlohmann::json& request) +{ + try { + return request.get(); + } + catch (const nlohmann::json::exception& e) { + throw exception(exception::INVALID_REQUEST, e.what()); + } +} + +// NOLINTNEXTLINE(misc-no-recursion) -- there is one level of recursion for batch requests +nlohmann::json dispatcher_private::process_request(const nlohmann::json& request) +{ + if (request.is_array()) { + if (request.empty()) { + this->q_ptr->on_request(); + this->q_ptr->on_request_processed({}, exception::INVALID_REQUEST); + return dispatcher_private::generate_error_response( + exception(exception::INVALID_REQUEST, err_empty_batch), nlohmann::json(nullptr) + ); + } + + auto response = nlohmann::json::array(); + for (const auto& req : request) { + if (!req.is_object()) { + this->q_ptr->on_request(); + const auto r = dispatcher_private::generate_error_response( + exception(exception::INVALID_REQUEST, err_not_jsonrpc_2_0_request), nlohmann::json(nullptr) + ); + + response.push_back(r); + this->q_ptr->on_request_processed({}, exception::INVALID_REQUEST); + } + else if (const auto res = this->process_request(req); !res.is_discarded()) { + response.push_back(res); + } + } + + return response.empty() ? nlohmann::json(nlohmann::json::value_t::discarded) : response; + } + + this->q_ptr->on_request(); + auto request_id = request.contains("id") ? request["id"] : nlohmann::json(nullptr); + if (!is_valid_request_id(request_id)) { + request_id = nlohmann::json(nullptr); + } + + std::string method; + try { + auto req = dispatcher_private::parse_request(request); + dispatcher_private::validate_request(req); + method = req.method; + request_id = req.id; + + const auto res = this->invoke(method, req.params); + if (!req.id.is_discarded()) { + return nlohmann::json({{"jsonrpc", "2.0"}, {"result", res}, {"id", req.id}}); + } + + // NOLINTNEXTLINE(modernize-return-braced-init-list) -- braced init will create a JSON array + return nlohmann::json(nlohmann::json::value_t::discarded); + } + catch (const exception& e) { + this->q_ptr->on_request_processed(method, e.code()); + return request_id.is_discarded() ? nlohmann::json(nlohmann::json::value_t::discarded) + : dispatcher_private::generate_error_response(e, request_id); + } + catch (const std::exception& e) { + this->q_ptr->on_request_processed(method, exception::INTERNAL_ERROR); + return request_id.is_discarded() ? nlohmann::json(nlohmann::json::value_t::discarded) + : dispatcher_private::generate_error_response( + exception(exception::INTERNAL_ERROR, e.what()), request_id + ); + } +} + +void dispatcher_private::validate_request(const jsonrpc_request& r) +{ + if (r.jsonrpc != "2.0") { + throw json_rpc::exception(json_rpc::exception::INVALID_REQUEST, json_rpc::err_not_jsonrpc_2_0_request); + } + + if (!r.params.is_array()) { + throw json_rpc::exception(json_rpc::exception::INVALID_PARAMS, json_rpc::err_bad_params_type); + } + + if (r.method.empty()) { + throw json_rpc::exception(json_rpc::exception::INVALID_REQUEST, json_rpc::err_empty_method); + } + + if (!is_valid_request_id(r.id)) { + throw json_rpc::exception(json_rpc::exception::INVALID_REQUEST, json_rpc::err_bad_id_type); + } +} + +nlohmann::json dispatcher_private::generate_error_response(const exception& e, const nlohmann::json& id) +{ + return nlohmann::json({{"jsonrpc", "2.0"}, {"error", e.to_json()}, {"id", id}}); +} + +dispatcher::handler_t dispatcher_private::get_method(const std::string& method) +{ + const shared_lock lock(this->m_methods_mutex); + if (const auto it = this->m_methods.find(method); it != this->m_methods.end()) { + return it->second; + } + + throw exception(exception::METHOD_NOT_FOUND, err_method_not_found); +} + +nlohmann::json dispatcher_private::invoke(const std::string& method, const nlohmann::json& params) +{ + const auto handler = this->get_method(method); + this->q_ptr->on_method(method); + const auto response = handler(params); + this->q_ptr->on_request_processed(method, 0); + return response; +} + +} // namespace wwa::json_rpc diff --git a/src/dispatcher_p.h b/src/dispatcher_p.h new file mode 100644 index 0000000..ce1a3c8 --- /dev/null +++ b/src/dispatcher_p.h @@ -0,0 +1,54 @@ +#ifndef FB656817_7041_48D5_80B2_347168163158 +#define FB656817_7041_48D5_80B2_347168163158 + +#include +#include +#include +#include +#include +#include "dispatcher.h" + +struct jsonrpc_request; + +namespace wwa::json_rpc { + +class exception; + +struct hasher { + using is_transparent = void; + + std::size_t operator()(std::string_view s) const noexcept + { + const std::hash h; + return h(s); + } +}; + +struct jsonrpc_request { + std::string jsonrpc; + std::string method; + nlohmann::json params; + nlohmann::json id; +}; + +class dispatcher_private { +public: + explicit dispatcher_private(dispatcher* q) : q_ptr(q) {} + void add_handler(std::string_view method, dispatcher::handler_t&& handler); + nlohmann::json process_request(const nlohmann::json& request); + static nlohmann::json generate_error_response(const exception& e, const nlohmann::json& id); + +private: + dispatcher* const q_ptr; + std::unordered_map> m_methods; + std::shared_mutex m_methods_mutex; + + static jsonrpc_request parse_request(const nlohmann::json& request); + static void validate_request(const jsonrpc_request& r); + dispatcher::handler_t get_method(const std::string& method); + nlohmann::json invoke(const std::string& method, const nlohmann::json& params); +}; + +} // namespace wwa::json_rpc + +#endif /* FB656817_7041_48D5_80B2_347168163158 */ diff --git a/src/exception.cpp b/src/exception.cpp new file mode 100644 index 0000000..d52e720 --- /dev/null +++ b/src/exception.cpp @@ -0,0 +1,3 @@ +#include "exception.h" + +wwa::json_rpc::exception::~exception() = default; diff --git a/src/exception.h b/src/exception.h new file mode 100644 index 0000000..ab48048 --- /dev/null +++ b/src/exception.h @@ -0,0 +1,70 @@ +#ifndef CC75354D_5C03_4B34_B773_96A9E6189611 +#define CC75354D_5C03_4B34_B773_96A9E6189611 + +#include +#include +#include +#include + +#include "export.h" + +namespace wwa::json_rpc { + +static constexpr std::string_view err_not_jsonrpc_2_0_request = "Not a JSON-RPC 2.0 request"; +static constexpr std::string_view err_invalid_params_passed_to_method = "Invalid parameters passed to method"; +static constexpr std::string_view err_method_not_found = "Method not found"; +static constexpr std::string_view err_empty_method = "Method cannot be empty"; +static constexpr std::string_view err_bad_params_type = "Parameters must be either an array or an object or omitted"; +static constexpr std::string_view err_bad_id_type = "ID must be either a number, a string, or null"; +static constexpr std::string_view err_empty_batch = "Empty batch request"; + +class WWA_JSONRPC_EXPORT exception : public std::exception { +public: + static constexpr int PARSE_ERROR = -32700; + static constexpr int INVALID_REQUEST = -32600; + static constexpr int METHOD_NOT_FOUND = -32601; + static constexpr int INVALID_PARAMS = -32602; + static constexpr int INTERNAL_ERROR = -32603; + + template + exception(int code, std::string_view message, const T& data) : m_message(message), m_data(data), m_code(code) + {} + + exception(int code, std::string_view message) : m_message(message), m_code(code) {} + + exception(const exception&) = default; + exception(exception&&) = default; + exception& operator=(const exception&) = default; + exception& operator=(exception&&) = default; + + ~exception() override; + + [[nodiscard]] int code() const noexcept { return this->m_code; } + [[nodiscard]] const std::string& message() const noexcept { return this->m_message; } + [[nodiscard]] const nlohmann::json& data() const noexcept { return this->m_data; } + + [[nodiscard]] const char* what() const noexcept override { return this->m_message.c_str(); } + + [[nodiscard]] nlohmann::json to_json() const + { + nlohmann::json j{ + {"code", this->m_code}, + {"message", this->m_message}, + }; + + if (!this->m_data.is_null()) { + j["data"] = this->m_data; + } + + return j; + } + +private: + std::string m_message; + nlohmann::json m_data; + int m_code; +}; + +} // namespace wwa::json_rpc + +#endif /* CC75354D_5C03_4B34_B773_96A9E6189611 */ diff --git a/src/export.h b/src/export.h new file mode 100644 index 0000000..59bb112 --- /dev/null +++ b/src/export.h @@ -0,0 +1,29 @@ +#ifndef E66C1505_D447_4384_BB35_55B23FF31F0A +#define E66C1505_D447_4384_BB35_55B23FF31F0A + +#ifdef WWA_JSONRPC_STATIC_DEFINE +# define WWA_JSONRPC_EXPORT +# define WWA_JSONRPC_NO_EXPORT +#else +# ifdef wwa_jsonrpc_EXPORTS +/* We are building this library; export */ +# if defined _WIN32 || defined __CYGWIN__ +# define WWA_JSONRPC_EXPORT __declspec(dllexport) +# define WWA_JSONRPC_NO_EXPORT +# else +# define WWA_JSONRPC_EXPORT [[gnu::visibility("default")]] +# define WWA_JSONRPC_NO_EXPORT [[gnu::visibility("hidden")]] +# endif +# else +/* We are using this library; import */ +# if defined _WIN32 || defined __CYGWIN__ +# define WWA_JSONRPC_EXPORT __declspec(dllimport) +# define WWA_JSONRPC_NO_EXPORT +# else +# define WWA_JSONRPC_EXPORT [[gnu::visibility("default")]] +# define WWA_JSONRPC_NO_EXPORT [[gnu::visibility("hidden")]] +# endif +# endif +#endif + +#endif /* E66C1505_D447_4384_BB35_55B23FF31F0A */ diff --git a/src/traits.h b/src/traits.h new file mode 100644 index 0000000..05a12b8 --- /dev/null +++ b/src/traits.h @@ -0,0 +1,66 @@ +#ifndef DE443A53_EEA9_4918_BCFB_AE76A19FB197 +#define DE443A53_EEA9_4918_BCFB_AE76A19FB197 + +#include +#include + +namespace wwa::json_rpc::details { + +template +struct function_traits; + +template +struct function_traits { + using return_type = R; + using args_tuple = std::tuple; +}; + +template +struct function_traits : function_traits {}; + +template +struct function_traits { + using return_type = R; + using args_tuple = std::tuple; +}; + +template +struct function_traits : function_traits {}; + +template +struct function_traits : function_traits {}; + +template +struct function_traits : function_traits {}; + +template +struct function_traits : function_traits {}; + +template +struct function_traits : function_traits {}; + +template +struct function_traits : function_traits {}; + +template +struct function_traits : function_traits {}; + +template +struct function_traits> { + using return_type = R; + using args_tuple = std::tuple; +}; + +template +struct function_traits { +private: + using call_type = function_traits; + +public: + using return_type = typename call_type::return_type; + using args_tuple = typename call_type::args_tuple; +}; + +} // namespace wwa::json_rpc::details + +#endif /* DE443A53_EEA9_4918_BCFB_AE76A19FB197 */ diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..be6cc3b --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,23 @@ +add_executable( + test_jsonrpc + base.cpp + test_error_handling.cpp + test_exception.cpp + test_instrumentation.cpp + test_invocation.cpp + test_notifications.cpp +) + +target_compile_features(test_jsonrpc PRIVATE cxx_std_20) +target_link_libraries(test_jsonrpc PRIVATE ${PROJECT_NAME} GTest::gmock_main) + +if(ENABLE_MAINTAINER_MODE) + if(CMAKE_COMPILER_IS_CLANG) + target_compile_options(test_jsonrpc PRIVATE -Wno-weak-vtables -Wno-global-constructors) + endif() +endif() + +if(NOT CMAKE_CROSSCOMPILING) + include(GoogleTest) + gtest_discover_tests(test_jsonrpc) +endif() diff --git a/test/base.cpp b/test/base.cpp new file mode 100644 index 0000000..b3e6671 --- /dev/null +++ b/test/base.cpp @@ -0,0 +1,88 @@ +#include "base.h" + +#include +#include + +BaseDispatcherTest::BaseDispatcherTest() +{ + this->m_dispatcher.add("subtract", &BaseDispatcherTest::subtract, this); + this->m_dispatcher.add("subtract_p", &BaseDispatcherTest::subtract_p, this); + this->m_dispatcher.add("notification", &BaseDispatcherTest::notification, this); + + this->m_dispatcher.add("s_subtract", &BaseDispatcherTest::s_subtract); + this->m_dispatcher.add("s_subtract_p", &BaseDispatcherTest::s_subtract_p); + this->m_dispatcher.add("s_notification", &BaseDispatcherTest::s_notification); + + this->m_dispatcher.add("no_params", &BaseDispatcherTest::no_params, this); + + this->m_dispatcher.add("sum", &BaseDispatcherTest::sum, this); + this->m_dispatcher.add("get_data", &BaseDispatcherTest::get_data, this); + this->m_dispatcher.add("notify_hello", [](int) { /* Do nothing */ }); + + this->m_dispatcher.add("sumv", &BaseDispatcherTest::sumv, this); + this->m_dispatcher.add("s_sumv", &BaseDispatcherTest::s_sumv); + this->m_dispatcher.add("throwing", []() { throw std::invalid_argument("test"); }); +} + +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) +int BaseDispatcherTest::subtract(const subtract_params& params) const +{ + return params.minuend - params.subtrahend; +} + +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) +int BaseDispatcherTest::subtract_p(int minuend, int subtrahend) const +{ + return minuend - subtrahend; +} + +void BaseDispatcherTest::notification() const +{ + /* Do nothing */ +} + +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) +int BaseDispatcherTest::no_params() const noexcept +{ + return 24; // NOLINT(readability-magic-numbers) +} + +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) +int BaseDispatcherTest::sum(int a, int b, int c) const +{ + return a + b + c; +} + +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) +int BaseDispatcherTest::sumv(const nlohmann::json& params) const +{ + return BaseDispatcherTest::s_sumv(params); +} + +int BaseDispatcherTest::s_sumv(const nlohmann::json& params) +{ + std::vector v; + params.get_to(v); + return std::accumulate(v.begin(), v.end(), 0); +} + +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) +nlohmann::json BaseDispatcherTest::get_data() const +{ + return nlohmann::json::array({"hello", 5}); // NOLINT(readability-magic-numbers) +} + +int BaseDispatcherTest::s_subtract(const subtract_params& params) +{ + return params.minuend - params.subtrahend; +} + +int BaseDispatcherTest::s_subtract_p(int minuend, int subtrahend) +{ + return minuend - subtrahend; +} + +void BaseDispatcherTest::s_notification() +{ + /* Do nothing */ +} diff --git a/test/base.h b/test/base.h new file mode 100644 index 0000000..ef89437 --- /dev/null +++ b/test/base.h @@ -0,0 +1,37 @@ +#ifndef DF466239_E1AA_4975_87F2_5A43B9623F91 +#define DF466239_E1AA_4975_87F2_5A43B9623F91 + +#include +#include +#include "../src/dispatcher.h" + +struct subtract_params { + int minuend; + int subtrahend; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(subtract_params, minuend, subtrahend); + +class BaseDispatcherTest : public ::testing::Test { +public: + BaseDispatcherTest(); + wwa::json_rpc::dispatcher& dispatcher() noexcept { return this->m_dispatcher; } + +private: + wwa::json_rpc::dispatcher m_dispatcher; + + [[nodiscard]] int subtract(const subtract_params& params) const; + [[nodiscard]] int subtract_p(int minuend, int subtrahend) const; + void notification() const; + [[nodiscard]] int no_params() const noexcept; + [[nodiscard]] int sum(int a, int b, int c) const; + [[nodiscard]] int sumv(const nlohmann::json& params) const; + [[nodiscard]] nlohmann::json get_data() const; + + [[nodiscard]] static int s_sumv(const nlohmann::json& params); + [[nodiscard]] static int s_subtract(const subtract_params& params); + [[nodiscard]] static int s_subtract_p(int minuend, int subtrahend); + static void s_notification(); +}; + +#endif /* DF466239_E1AA_4975_87F2_5A43B9623F91 */ diff --git a/test/test_error_handling.cpp b/test/test_error_handling.cpp new file mode 100644 index 0000000..86389cd --- /dev/null +++ b/test/test_error_handling.cpp @@ -0,0 +1,219 @@ +#include +#include + +#include +#include + +#include "base.h" + +using namespace std::string_literals; +using namespace nlohmann::json_literals; + +class ErrorHandlingTest : public BaseDispatcherTest, + public testing::WithParamInterface> {}; + +TEST_P(ErrorHandlingTest, TestErrorHandling) +{ + const auto& [input, expected] = GetParam(); + const auto response = this->dispatcher().parse_and_process_request(input); + const auto actual = nlohmann::json::parse(response); + + EXPECT_EQ(actual, expected); +} + +// clang-format off +INSTANTIATE_TEST_SUITE_P(RequestParsingFromStandard, ErrorHandlingTest, testing::Values( + // rpc call with invalid JSON + std::make_tuple(R"({"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz])"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::PARSE_ERROR }, + { "message", R"([json.exception.parse_error.101] parse error at line 1, column 40: syntax error while parsing object - invalid literal; last read: '"foobar, "p'; expected '}')" } + } + }, + { "id", nullptr }, + { "jsonrpc", "2.0" } + })), + // rpc call with invalid Request object + std::make_tuple(R"({"jsonrpc": "2.0", "method": 1, "params": "bar"})"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::INVALID_REQUEST }, + { "message", "[json.exception.type_error.302] type must be string, but is number" } + } + }, + { "id", nullptr }, + { "jsonrpc", "2.0" } + })), + // rpc call with an empty Array + std::make_tuple("[]"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::INVALID_REQUEST }, + { "message", wwa::json_rpc::err_empty_batch } + } + }, + { "id", nullptr }, + { "jsonrpc", "2.0" } + })), + // rpc call Batch, invalid JSON + std::make_tuple(R"([{"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},{"jsonrpc": "2.0", "method"])"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::PARSE_ERROR }, + { "message", "[json.exception.parse_error.101] parse error at line 1, column 95: syntax error while parsing object separator - unexpected ']'; expected ':'" } + } + }, + { "id", nullptr }, + { "jsonrpc", "2.0" } + })), + // rpc call with an invalid Batch (but not empty) + std::make_tuple("[1]"s, nlohmann::json({ + { + { + "error", { + { "code", wwa::json_rpc::exception::INVALID_REQUEST }, + { "message", wwa::json_rpc::err_not_jsonrpc_2_0_request } + } + }, + { "id", nullptr }, + { "jsonrpc", "2.0" } + } + })), + // rpc call with invalid Batch + std::make_tuple("[1,2,3]"s, nlohmann::json({ + { { "error", { { "code", wwa::json_rpc::exception::INVALID_REQUEST }, { "message", wwa::json_rpc::err_not_jsonrpc_2_0_request } } }, { "id", nullptr }, { "jsonrpc", "2.0" } }, + { { "error", { { "code", wwa::json_rpc::exception::INVALID_REQUEST }, { "message", wwa::json_rpc::err_not_jsonrpc_2_0_request } } }, { "id", nullptr }, { "jsonrpc", "2.0" } }, + { { "error", { { "code", wwa::json_rpc::exception::INVALID_REQUEST }, { "message", wwa::json_rpc::err_not_jsonrpc_2_0_request } } }, { "id", nullptr }, { "jsonrpc", "2.0" } } + })) +)); + +INSTANTIATE_TEST_SUITE_P(RequestParsing, ErrorHandlingTest, testing::Values( + // Empty input + std::make_tuple(""s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::PARSE_ERROR }, + { "message", "[json.exception.parse_error.101] parse error at line 1, column 1: attempting to parse an empty input; check that your input string or stream contains the expected JSON" } + } + }, + { "id", nullptr }, + { "jsonrpc", "2.0" } + })), + // Empty method + std::make_tuple(R"({"jsonrpc": "2.0", "method": "", "id": 3})"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::INVALID_REQUEST }, + { "message", wwa::json_rpc::err_empty_method } + } + }, + { "id", 3 }, + { "jsonrpc", "2.0" } + })), + // Invalid JSON-RPC version + std::make_tuple(R"({"jsonrpc": "12.0", "method": ""})"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::INVALID_REQUEST }, + { "message", wwa::json_rpc::err_not_jsonrpc_2_0_request } + } + }, + { "id", nullptr }, + { "jsonrpc", "2.0" } + })), + // Missing field + std::make_tuple(R"({"jsonrpc": "2.0"})"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::INVALID_REQUEST }, + { "message", "[json.exception.out_of_range.403] key 'method' not found" } + } + }, + { "id", nullptr }, + { "jsonrpc", "2.0" } + })), + // Recursive batch + std::make_tuple(R"([[]])"s, nlohmann::json({ + { + { + "error", { + { "code", wwa::json_rpc::exception::INVALID_REQUEST }, + { "message", wwa::json_rpc::err_not_jsonrpc_2_0_request } + } + }, + { "id", nullptr }, + { "jsonrpc", "2.0" } + } + })), + // Bad ID + std::make_tuple(R"({"jsonrpc": "2.0", "method": "method", "id": true})"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::INVALID_REQUEST }, + { "message", wwa::json_rpc::err_bad_id_type } + } + }, + { "id", nullptr }, + { "jsonrpc", "2.0" } + })), + // Bad params + std::make_tuple(R"({"jsonrpc": "2.0", "method": "method", "id": 3, "params": 1})"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::INVALID_PARAMS }, + { "message", wwa::json_rpc::err_bad_params_type } + } + }, + { "id", 3 }, + { "jsonrpc", "2.0" } + })) +)); + +INSTANTIATE_TEST_SUITE_P(MethodInvocation, ErrorHandlingTest, testing::Values( + // rpc call of non-existent method + std::make_tuple(R"({"jsonrpc": "2.0", "method": "foobar", "id": "1"})"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::METHOD_NOT_FOUND }, + { "message", wwa::json_rpc::err_method_not_found } + } + }, + { "id", "1" }, + { "jsonrpc", "2.0" } + })), + // Too many params (positional) + std::make_tuple(R"({"jsonrpc": "2.0", "method": "no_params", "id": 3, "params": [1]})"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::INVALID_PARAMS }, + { "message", wwa::json_rpc::err_invalid_params_passed_to_method } + } + }, + { "id", 3 }, + { "jsonrpc", "2.0" } + })), + // Too many params (named) + std::make_tuple(R"({"jsonrpc": "2.0", "method": "no_params", "id": 3, "params": {}})"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::INVALID_PARAMS }, + { "message", wwa::json_rpc::err_invalid_params_passed_to_method } + } + }, + { "id", 3 }, + { "jsonrpc", "2.0" } + })), + // Wrong params type + std::make_tuple(R"({"jsonrpc": "2.0", "method": "subtract_p", "id": 3, "params": ["a", "b"]})"s, nlohmann::json({ + { + "error", { + { "code", wwa::json_rpc::exception::INVALID_PARAMS }, + { "message", "[json.exception.type_error.302] type must be number, but is string" } + } + }, + { "id", 3 }, + { "jsonrpc", "2.0" } + })) +)); +// clang-format on diff --git a/test/test_exception.cpp b/test/test_exception.cpp new file mode 100644 index 0000000..4cc7771 --- /dev/null +++ b/test/test_exception.cpp @@ -0,0 +1,59 @@ +#include +#include +#include + +#include "../src/exception.h" + +using namespace std::string_view_literals; + +TEST(ExceptionTest, TestExceptionConstructor) +{ + const auto code = wwa::json_rpc::exception::PARSE_ERROR; + const auto message = "Parse error"sv; + + const wwa::json_rpc::exception e(code, message); + + EXPECT_EQ(e.code(), code); + EXPECT_EQ(e.message(), message); + EXPECT_STRCASEEQ(e.what(), message.data()); + EXPECT_EQ(e.data().is_null(), true); +} + +TEST(ExceptionTest, TestExceptionConstructorWithData) +{ + const auto code = wwa::json_rpc::exception::PARSE_ERROR; + const auto message = "Parse error"sv; + const auto data = 123U; + + const wwa::json_rpc::exception e(code, message, data); + + EXPECT_EQ(e.code(), code); + EXPECT_EQ(e.message(), message); + EXPECT_STRCASEEQ(e.what(), message.data()); + EXPECT_EQ(e.data(), nlohmann::json(data)); +} + +TEST(ExceptionTest, TestExceptionToJson) +{ + const auto code = wwa::json_rpc::exception::PARSE_ERROR; + const auto message = "Parse error"sv; + const auto data = 123U; + const auto expected = nlohmann::json{{"code", code}, {"message", message}, {"data", data}}; + + const wwa::json_rpc::exception e(code, message, data); + const auto j = e.to_json(); + + EXPECT_EQ(j, expected); +} + +TEST(ExceptionTest, TestExceptionToJsonNoData) +{ + const auto code = wwa::json_rpc::exception::PARSE_ERROR; + const auto message = "Parse error"sv; + const auto expected = nlohmann::json{{"code", code}, {"message", message}}; + + const wwa::json_rpc::exception e(code, message); + const auto j = e.to_json(); + + EXPECT_EQ(j, expected); +} diff --git a/test/test_instrumentation.cpp b/test/test_instrumentation.cpp new file mode 100644 index 0000000..1064764 --- /dev/null +++ b/test/test_instrumentation.cpp @@ -0,0 +1,73 @@ +#include +#include +#include + +#include "../src/dispatcher.h" + +class mocked_dispatcher : public wwa::json_rpc::dispatcher { +public: + MOCK_METHOD(void, on_request, (), (override)); + MOCK_METHOD(void, on_method, (const std::string&), (override)); + MOCK_METHOD(void, on_request_processed, (const std::string&, int), (override)); +}; + +class InstrumentationTest : public ::testing::Test { +public: + InstrumentationTest() + { + this->m_dispatcher.add("add", [](int a, int b) { return a + b; }); + this->m_dispatcher.add("subtract", [](int a, int b) { return a - b; }); + } + + mocked_dispatcher& dispatcher() noexcept { return this->m_dispatcher; } + +private: + testing::StrictMock m_dispatcher; +}; + +using namespace nlohmann::json_literals; + +TEST_F(InstrumentationTest, BadRequest) +{ + const auto input = R"([])"_json; + auto& dispatcher = this->dispatcher(); + + { + const testing::InSequence s; + EXPECT_CALL(dispatcher, on_request()); + EXPECT_CALL(dispatcher, on_request_processed(std::string{}, wwa::json_rpc::exception::INVALID_REQUEST)); + } + + dispatcher.process_request(input); +} + +TEST_F(InstrumentationTest, BatchRequest) +{ + const auto input = R"([ + {"jsonrpc": "2.0", "method": "add", "params": [1, 2], "id": 1}, + {"jsonrpc": "2.0", "method": "subtract", "params": [2, 1], "id": 2}, + {"jsonrpc": "2.0", "method": "add", "params": ["2", "3"], "id": 3}, + {"jsonrpc": "2.0", "method": "bad", "id": 4} + ])"_json; + auto& dispatcher = this->dispatcher(); + + { + const testing::InSequence s; + EXPECT_CALL(dispatcher, on_request()); + EXPECT_CALL(dispatcher, on_method("add")); + EXPECT_CALL(dispatcher, on_request_processed("add", 0)); + + EXPECT_CALL(dispatcher, on_request()); + EXPECT_CALL(dispatcher, on_method("subtract")); + EXPECT_CALL(dispatcher, on_request_processed("subtract", 0)); + + EXPECT_CALL(dispatcher, on_request()); + EXPECT_CALL(dispatcher, on_method("add")); + EXPECT_CALL(dispatcher, on_request_processed("add", wwa::json_rpc::exception::INVALID_PARAMS)); + + EXPECT_CALL(dispatcher, on_request()); + EXPECT_CALL(dispatcher, on_request_processed("bad", wwa::json_rpc::exception::METHOD_NOT_FOUND)); + } + + dispatcher.process_request(input); +} diff --git a/test/test_invocation.cpp b/test/test_invocation.cpp new file mode 100644 index 0000000..0056e29 --- /dev/null +++ b/test/test_invocation.cpp @@ -0,0 +1,51 @@ +#include + +#include +#include + +#include "base.h" + +using namespace nlohmann::json_literals; + +class MethodInvocationTest : public BaseDispatcherTest, + public testing::WithParamInterface> {}; + +TEST_P(MethodInvocationTest, TestMethodCalls) +{ + const auto& [input, expected] = GetParam(); + const auto response = this->dispatcher().process_request(input); + const auto actual = nlohmann::json::parse(response); + + EXPECT_EQ(actual, expected); +} + +// clang-format off +INSTANTIATE_TEST_SUITE_P(MethodInvocation, MethodInvocationTest, testing::Values( + std::make_tuple(R"({"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 3})"_json, R"({"jsonrpc":"2.0","result":19,"id":3})"_json), + std::make_tuple(R"({"jsonrpc": "2.0", "method": "s_subtract", "params": {"minuend": 40, "subtrahend": 20}, "id": 3})"_json, R"({"jsonrpc":"2.0","result":20,"id":3})"_json), + std::make_tuple(R"({"jsonrpc": "2.0", "method": "subtract_p", "params": [9, 2], "id": 3})"_json, R"({"jsonrpc":"2.0","result":7,"id":3})"_json), + std::make_tuple(R"({"jsonrpc": "2.0", "method": "s_subtract_p", "params": [1, 1], "id": 3})"_json, R"({"jsonrpc":"2.0","result":0,"id":3})"_json), + std::make_tuple(R"({"jsonrpc": "2.0", "method": "no_params", "id": 3})"_json, R"({"jsonrpc":"2.0","result":24,"id":3})"_json), + std::make_tuple(R"({"jsonrpc": "2.0", "method": "no_params", "params": [], "id": 3})"_json, R"({"jsonrpc":"2.0","result":24,"id":3})"_json), + std::make_tuple( + R"([ + {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, + {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, + {"jsonrpc": "2.0", "method": "subtract_p", "params": [42,23], "id": "2"}, + {"foo": "boo"}, + {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"}, + {"jsonrpc": "2.0", "method": "get_data", "id": "9"} + ])"_json, + R"([ + {"jsonrpc":"2.0","result":7,"id":"1"}, + {"jsonrpc":"2.0","result":19,"id":"2"}, + {"jsonrpc":"2.0","error":{"code":-32600,"message":"[json.exception.out_of_range.403] key 'jsonrpc' not found"},"id":null}, + {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":"5"}, + {"jsonrpc":"2.0","result":["hello",5],"id":"9"} + ])"_json + ), + std::make_tuple(R"({"jsonrpc": "2.0", "method": "throwing", "id": 1})"_json, R"({"jsonrpc":"2.0","error":{"code":-32603,"message":"test"},"id":1})"_json), + std::make_tuple(R"({"jsonrpc": "2.0", "method": "sumv", "params": [1,2,4], "id": "1"})"_json, R"({"jsonrpc":"2.0","result":7,"id":"1"})"_json), + std::make_tuple(R"({"jsonrpc": "2.0", "method": "s_sumv", "params": [1,2,4], "id": "1"})"_json, R"({"jsonrpc":"2.0","result":7,"id":"1"})"_json) +)); +// clang-format on diff --git a/test/test_notifications.cpp b/test/test_notifications.cpp new file mode 100644 index 0000000..6032052 --- /dev/null +++ b/test/test_notifications.cpp @@ -0,0 +1,28 @@ +#include + +#include + +#include "base.h" + +using namespace std::string_literals; + +class NotificationsTest : public BaseDispatcherTest, + public testing::WithParamInterface {}; + +TEST_P(NotificationsTest, TestNotifications) +{ + const auto& input = GetParam(); + const std::string expected{}; + const auto actual = this->dispatcher().parse_and_process_request(input); + + EXPECT_EQ(actual, expected); +} + +// clang-format off +INSTANTIATE_TEST_SUITE_P(Notifications, NotificationsTest, testing::Values( + R"({"jsonrpc": "2.0", "method": "notification"})"s, + R"({"jsonrpc": "2.0", "method": "s_notification"})"s, + // rpc call Batch (all notifications) + R"([{"jsonrpc": "2.0", "method": "notification"},{"jsonrpc": "2.0", "method": "s_notification"}])"s +)); +// clang-format on diff --git a/vcpkg b/vcpkg new file mode 160000 index 0000000..10b7a17 --- /dev/null +++ b/vcpkg @@ -0,0 +1 @@ +Subproject commit 10b7a178346f3f0abef60cecd5130e295afd8da4 diff --git a/vcpkg-configuration.json b/vcpkg-configuration.json new file mode 100644 index 0000000..c94aa08 --- /dev/null +++ b/vcpkg-configuration.json @@ -0,0 +1,14 @@ +{ + "default-registry": { + "kind": "git", + "baseline": "b2a47d316de1f3625ea43a7ca3e42dd28c52ece7", + "repository": "https://github.com/microsoft/vcpkg" + }, + "registries": [ + { + "kind": "artifact", + "location": "https://github.com/microsoft/vcpkg-ce-catalog/archive/refs/heads/main.zip", + "name": "microsoft" + } + ] +} diff --git a/vcpkg.json b/vcpkg.json new file mode 100644 index 0000000..3d3544e --- /dev/null +++ b/vcpkg.json @@ -0,0 +1,6 @@ +{ + "dependencies": [ + "nlohmann-json", + "gtest" + ] +}