├── .circleci └── config.yml ├── .credo.exs ├── .dialyzer └── .keep ├── .formatter.exs ├── .gitattributes ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── coveralls.json ├── lib ├── mix │ └── tasks │ │ └── sparrow_certs_dev.ex ├── sparrow.ex └── sparrow │ ├── api.ex │ ├── apns.ex │ ├── apns │ ├── errors.ex │ ├── notification.ex │ ├── notification │ │ └── sound.ex │ ├── pool │ │ └── supervisor.ex │ ├── supervisor.ex │ ├── token.ex │ ├── token_bearer.ex │ └── token_bearer │ │ └── state.ex │ ├── fcm │ └── v1 │ │ ├── android.ex │ │ ├── android │ │ └── notification.ex │ │ ├── apns.ex │ │ ├── notification.ex │ │ ├── pool │ │ └── supervisor.ex │ │ ├── project_ids_bearer.ex │ │ ├── supervisor.ex │ │ ├── token_bearer.ex │ │ ├── webpush.ex │ │ └── webpush │ │ └── notification.ex │ ├── fcm_v1.ex │ ├── h2_client_adapter.ex │ ├── h2_client_adapter │ └── chatterbox.ex │ ├── h2_worker.ex │ ├── h2_worker │ ├── authentication │ │ ├── certificate_based.ex │ │ └── token_based.ex │ ├── config.ex │ ├── pool.ex │ ├── pool │ │ └── config.ex │ ├── request.ex │ ├── request_set.ex │ ├── request_state.ex │ └── state.ex │ ├── pools_warden.ex │ └── telemetry │ └── timer.ex ├── mix.exs ├── mix.lock ├── sparrow_token.json ├── sparrow_token2.json └── test ├── api_test.exs ├── apns ├── error_test.exs ├── manual │ └── real_ios_test.exs ├── response_processing_test.exs └── token_bearer_test.exs ├── apns_test.exs ├── fcm ├── manual │ ├── real_android_test.exs │ └── real_webpush_test.exs └── v1 │ ├── android_config_test.exs │ ├── apns_config_test.exs │ ├── notification_test.exs │ ├── token_bearer_test.exs │ └── webpush_config_test.exs ├── fcmv1_test.exs ├── h2_client_adapter └── chatterbox_test.exs ├── h2_integration ├── certificate_rejected_test.exs ├── certificate_required_test.exs ├── client_server_test.exs ├── h2_adapter_instability_test.exs └── token_based_authorisation_test.exs ├── h2_worker ├── config_test.exs ├── connection_test.exs ├── pool │ └── config_test.exs ├── pool_test.exs ├── request_set_test.exs └── request_test.exs ├── h2_worker_test.exs ├── helpers ├── cerificate_helper.ex ├── cowboy_handlers │ ├── authenticate_handler.ex │ ├── connection_handler.ex │ ├── echo_body_handler.ex │ ├── echo_client_cerificate_handler.ex │ ├── error_response_handler.ex │ ├── header_to_body_echo_handler.ex │ ├── lost_conn_handler.ex │ ├── ok_fcm_handler.ex │ ├── ok_response_handler.ex │ ├── reject_certificate_handler.ex │ └── timeout_handler.ex ├── mocks.ex ├── setup_helper.ex └── token_helper.ex ├── pools_warden_test.exs ├── sparrow_test.exs └── test_helper.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | executors: 4 | elixir: 5 | parameters: 6 | elixir: 7 | type: string 8 | erlang: 9 | type: string 10 | docker: 11 | - image: hexpm/elixir:<< parameters.elixir >>-erlang-<< parameters.erlang >>-alpine-3.21.3 12 | environment: 13 | - MIX_ENV=test 14 | 15 | jobs: 16 | test: 17 | parameters: 18 | elixir: 19 | type: string 20 | erlang: 21 | type: string 22 | executor: 23 | name: elixir 24 | elixir: << parameters.elixir >> 25 | erlang: << parameters.erlang >> 26 | steps: 27 | - checkout 28 | - run: apk add git 29 | - run: mix do local.rebar --force, local.hex --force 30 | - restore_cache: 31 | key: deps-{{ checksum "mix.lock" }} 32 | - run: mix do deps.get, deps.compile 33 | - save_cache: 34 | key: deps-{{ checksum "mix.lock" }} 35 | paths: 36 | - _build 37 | - deps 38 | - run: mix compile 39 | - run: mix format --check-formatted 40 | - run: mix credo --strict 41 | - restore_cache: 42 | key: dialyzer-{{ checksum "mix.lock" }} 43 | - run: 44 | name: Create PLTs 45 | command: | 46 | mkdir -p .dialyzer 47 | mix dialyzer --plt 48 | - save_cache: 49 | key: dialyzer-{{ checksum "mix.lock" }} 50 | paths: 51 | - .dialyzer 52 | - run: mix dialyzer --halt-exit-status 53 | - run: 54 | name: Run tests 55 | command: | 56 | mix sparrow.certs.dev 57 | mix test 58 | 59 | workflows: 60 | default: 61 | jobs: 62 | - test: 63 | matrix: 64 | parameters: 65 | elixir: [1.17.3, 1.18.3] 66 | erlang: [26.2.5.12, 27.3.4] 67 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | files: %{ 6 | included: ["lib/"], 7 | excluded: [~r"/_build/", ~r"/deps/", "test/"] 8 | }, 9 | requires: [], 10 | strict: true, 11 | checks: [ 12 | {Credo.Check.Consistency.ExceptionNames}, 13 | {Credo.Check.Consistency.LineEndings}, 14 | {Credo.Check.Consistency.SpaceAroundOperators}, 15 | {Credo.Check.Consistency.SpaceInParentheses}, 16 | {Credo.Check.Consistency.TabsOrSpaces}, 17 | {Credo.Check.Design.DuplicatedCode, excluded_macros: [:setup, :test]}, 18 | {Credo.Check.Design.TagTODO, exit_status: 0}, 19 | {Credo.Check.Design.TagFIXME}, 20 | {Credo.Check.Design.AliasUsage, false}, 21 | {Credo.Check.Readability.FunctionNames}, 22 | {Credo.Check.Readability.LargeNumbers}, 23 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 90}, 24 | {Credo.Check.Readability.ModuleAttributeNames}, 25 | {Credo.Check.Readability.ModuleDoc}, 26 | {Credo.Check.Readability.ModuleNames}, 27 | {Credo.Check.Readability.ParenthesesInCondition}, 28 | {Credo.Check.Readability.PredicateFunctionNames}, 29 | {Credo.Check.Readability.TrailingBlankLine}, 30 | {Credo.Check.Readability.TrailingWhiteSpace}, 31 | {Credo.Check.Readability.VariableNames}, 32 | 33 | {Credo.Check.Refactor.ABCSize, max_size: 34}, 34 | {Credo.Check.Refactor.CondStatements}, 35 | {Credo.Check.Refactor.FunctionArity}, 36 | {Credo.Check.Refactor.MatchInCondition}, 37 | {Credo.Check.Refactor.PipeChainStart}, 38 | {Credo.Check.Refactor.CyclomaticComplexity}, 39 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 40 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 41 | {Credo.Check.Refactor.Nesting}, 42 | {Credo.Check.Refactor.UnlessWithElse}, 43 | 44 | {Credo.Check.Warning.IExPry}, 45 | {Credo.Check.Warning.IoInspect}, 46 | {Credo.Check.Warning.OperationOnSameValues}, 47 | {Credo.Check.Warning.BoolOperationOnSameValues}, 48 | {Credo.Check.Warning.UnusedEnumOperation}, 49 | {Credo.Check.Warning.UnusedKeywordOperation}, 50 | {Credo.Check.Warning.UnusedListOperation}, 51 | {Credo.Check.Warning.UnusedStringOperation}, 52 | {Credo.Check.Warning.UnusedTupleOperation}, 53 | {Credo.Check.Warning.OperationWithConstantResult}, 54 | 55 | {Credo.Check.Design.DuplicatedCode, excluded_macros: [:setup, :test]}, 56 | ] 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /.dialyzer/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esl/sparrow/03b218d5553256020e46841e05836efae1b4dfc9/.dialyzer/.keep -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | line_length: 80, 5 | locals_without_parens: [ 6 | called: 1 7 | ] 8 | ] 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | 9 | A clear and concise description of what the bug is. 10 | 11 | **To Reproduce** 12 | 13 | Steps to reproduce the behavior: 14 | 1. Go to '...' 15 | 2. Scroll down to '....' 16 | 3. See error 17 | 18 | **Expected behavior** 19 | 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Logs** 27 | 28 | If applicable, add logs to help explain your problem. 29 | 30 | **Desktop (please complete the following information):** 31 | 32 | - OS: [e.g. iOS] 33 | - Erlang OTP release [e.g. 21] 34 | - Elixir version [e.g. 1.6.6] 35 | - Sparrow Version [e.g. 0.1] 36 | 37 | **Test** 38 | 39 | If applicable, provide a failing test reproducing the issue. 40 | 41 | **Additional context** 42 | 43 | Add any other context about the problem here. 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | 9 | A clear and concise description of what the problem is. Ex. "When trying to [...] I need to [...] because [...] is missing" 10 | 11 | **Describe the solution you'd like** 12 | 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Test** 20 | 21 | If applicable, provide a failing test showing/testing expected feature. 22 | 23 | **Additional context** 24 | 25 | Add any other context or screenshots about the feature request here. 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | sparrow-*.tar 24 | 25 | /.dialyzer/ 26 | /.elixir_ls/ 27 | /.vscode/ 28 | /log/ 29 | *.log 30 | *.coverdata 31 | /priv/ 32 | *.pem 33 | *.p8 34 | /test/priv 35 | .DS_Store 36 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at general@erlang-solutions.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | This document aims to provide guidelines for merging changesets into the Sparrow master branch. 4 | 5 | For the purpose of this document, we classify changesets/pull requests as: 6 | 7 | * big: major changes to core code 8 | * non-trivial: significant changes, fixes and improvements - especially changes to the core modules. 9 | * trivial: minor bugfixes, small changes to auxiliary modules, changes to documentation, and patches which change only the test code. 10 | 11 | ## Writing & Testing Code: 12 | 13 | Write type specification and function signatures because they are remarkably helpful when it comes to reading the source. 14 | Use `mix format` for formatting the code. 15 | Test newly added code. Ensure the test coverage is not decreased. When adding a new module `.ex`, remember to add `_test.exs` in the test directory in the corresponding subdirectory. 16 | 17 | What makes a good comment? 18 | Write about why something is done a certain way. 19 | E.g. explain a why a decision was made or describe a subtle but tricky case. 20 | We can read a test case or the source, respectively, to see **what** the code does or **how** it does it. 21 | Comments should give us more insight into **why** something was done (the reasoning). 22 | 23 | ## 1. Preparation 24 | 25 | ### Branch and code 26 | 27 | Always create a branch with a descriptive name from an up-to-date master. 28 | Do your work in the branch, push it to the ESL repository if you have access to it, otherwise to your own repository. 29 | 30 | ### Run tests 31 | 32 | When done, run `mix test` and write tests related to what you've done. 33 | 34 | ### Check coding style 35 | 36 | Use `mix format` for formatting the code. 37 | 38 | ### Push 39 | 40 | Push the changes and create a pull request to master, following the PR description template. 41 | Make sure all Travis tests pass (if only some jobs fail it is advisable to restart them, since they sometimes 42 | fail at random). 43 | 44 | ## 2. Review 45 | 46 | Both trivial and non-trivial PRs have to be reviewed by at least one other person from the core development team. 47 | For big changesets consult the Tech Lead. 48 | The reviewer's remarks should be placed as inline comments on github for future reference. 49 | 50 | Then, apply the reviewer's suggestions. 51 | If the changes are limited to documentation or code formatting, remember to prefix commit message with "[skip ci]" so that the tests results are not a concern. 52 | 53 | The reviewer should make sure all of their suggestions are applied. 54 | It is the reviewer who actually does the merge, so they take at least half of the responsibility. 55 | 56 | ## 3. Merging 57 | 58 | I. If your PR is not a trivial one, always rebase onto master. 59 | 60 | This is important, because someone may have merged something that is not compatible with your changes and it might be difficult to figure out who should fix it and how. 61 | For the same reason, it is recommended to tell your colleagues that you are about to merge something so that they do not merge at the same time. 62 | 63 | II. After rebase, push your branch with -f, make sure all tests pass. 64 | 65 | III. Tell your reviewer they can proceed. 66 | 67 | They hit the green button, and you can both celebrate. 68 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Provide a general summary of your changes in the Title above. 2 | 3 | ## Description 4 | 5 | Describe your changes in detail. 6 | 7 | ## Motivation and Context 8 | 9 | Why is this change required? What problem does it solve? 10 | If it fixes an open issue, please link to the issue here. 11 | 12 | ## How Has This Been Tested? 13 | 14 | Please describe how you tested your changes. 15 | 16 | ## Types of changes 17 | 18 | What types of changes does your code introduce? Put an `x` in all the boxes that apply: 19 | - [ ] Bug fix (non-breaking change which fixes an issue) 20 | - [ ] New feature (non-breaking change which adds a functionality) 21 | - [ ] Breaking change (fix or feature that would cause an existing functionality to change) 22 | 23 | ## Checklist: 24 | 25 | Go over all the following points, and put an `x` in all the boxes that apply. 26 | If you're unsure about any of these, don't hesitate to ask. We're here to help! 27 | - [ ] My code follows the code style of this project. 28 | - [ ] My change requires a change to the documentation. 29 | - [ ] I have updated the documentation accordingly. 30 | - [ ] I have read the **CONTRIBUTING** document. 31 | - [ ] I have added tests to cover my changes. 32 | - [ ] I have added tests to **TEST** my changes. 33 | - [ ] All new and existing tests passed. 34 | - [ ] Builds corresponding to the PR have passed. 35 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | import Config 4 | # This configuration is loaded before any dependency and is restricted 5 | # to this project. If another project depends on this project, this 6 | # file won't be loaded nor affect the parent project. For this reason, 7 | # if you want to provide default values for your application for 8 | # 3rd-party users, it should be done in your "mix.exs" file. 9 | 10 | # You can configure your application as: 11 | # 12 | # config :sparrow, key: :value 13 | # 14 | # and access this configuration in your application as: 15 | # 16 | # Application.get_env(:sparrow, :key) 17 | # 18 | # You can also configure a 3rd-party app: 19 | # 20 | config :logger, level: :info 21 | 22 | config :logger, :default_formatter, metadata: :all 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | config :sparrow, Sparrow.PoolsWarden, %{enabled: true} 31 | 32 | config :sparrow, Sparrow.H2ClientAdapter, %{ 33 | adapter: Sparrow.H2ClientAdapter.Chatterbox 34 | } 35 | 36 | import_config "#{Mix.env()}.exs" 37 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :sparrow, 4 | fcm: [ 5 | [ 6 | # TODO replace me with real data 7 | path_to_json: "sparrow_token.json" 8 | ] 9 | ], 10 | apns: [ 11 | dev: [ 12 | [ 13 | auth_type: :token_based, 14 | token_id: :some_atom_id 15 | ] 16 | ], 17 | prod: [ 18 | [ 19 | auth_type: :token_based, 20 | token_id: :some_atom_id 21 | ] 22 | ], 23 | tokens: [ 24 | [ 25 | # TODO replace me with real data 26 | token_id: :some_atom_id, 27 | key_id: "FAKE_KEY_ID", 28 | team_id: "FAKE_TEAM_ID", 29 | p8_file_path: "token.p8" 30 | ] 31 | ] 32 | ] 33 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :sparrow, 4 | fcm: [ 5 | # Authentication 6 | path_to_json: "priv/fcm/token/sparrow_token.json", 7 | # H2Worker config 8 | endpoint: "fcm.googleapis.com", 9 | port: 443, 10 | tls_opts: [], 11 | ping_interval: 5000, 12 | reconnect_attempts: 3, 13 | # pool config 14 | tags: [], 15 | worker_num: 3, 16 | raw_opts: [] 17 | ], 18 | apns: [ 19 | dev: [ 20 | [ 21 | # Token based authentication 22 | auth_type: :token_based, 23 | token_id: :some_atom_id, 24 | # H2Worker config 25 | endpoint: "api.development.push.apple.com", 26 | port: 443, 27 | tls_opts: [], 28 | ping_interval: 5000, 29 | reconnect_attempts: 3, 30 | # pool config 31 | tags: [:first_batch_clients, :beta_users], 32 | worker_num: 3, 33 | raw_opts: [] 34 | ], 35 | [ 36 | # Certificate based uthentication 37 | auth_type: :certificate_based, 38 | cert: "priv/apns/dev_cert.pem", 39 | key: "priv/apns/dev_key.pem", 40 | # H2Worker config 41 | endpoint: "api.push.apple.com", 42 | port: 443, 43 | tls_opts: [], 44 | ping_interval: 5000, 45 | reconnect_attempts: 3, 46 | # pool config 47 | tags: [:another_batch_clients], 48 | worker_num: 3, 49 | raw_opts: [] 50 | ] 51 | ], 52 | prod: [ 53 | [ 54 | # Authentication 55 | auth_type: :token_based, 56 | token_id: :some_other_id, 57 | # H2Worker config 58 | endpoint: "api.push.apple.com", 59 | port: 443, 60 | tls_opts: [], 61 | ping_interval: 5000, 62 | reconnect_attempts: 3, 63 | # pool config 64 | tags: [:test_prod, :alpha], 65 | worker_num: 3, 66 | raw_opts: [] 67 | ] 68 | ], 69 | tokens: [ 70 | [ 71 | # TODO replace me with real data 72 | token_id: :some_atom_id, 73 | key_id: "FAKE_KEY_ID", 74 | team_id: "FAKE_TEAM_ID", 75 | p8_file_path: "token.p8" 76 | ], 77 | [ 78 | # TODO replace me with real data 79 | token_id: :some_other_id, 80 | key_id: "FAKE_KEY_ID", 81 | team_id: "FAKE_TEAM_ID", 82 | p8_file_path: "token.p8" 83 | ] 84 | ] 85 | ] 86 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :sparrow, Sparrow.H2ClientAdapter, %{ 4 | adapter: Sparrow.H2ClientAdapter.Mock 5 | } 6 | 7 | config :sparrow, Sparrow.PoolsWarden, %{enabled: false} 8 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "lib/mix/.*", 4 | "test/.*" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /lib/mix/tasks/sparrow_certs_dev.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Sparrow.Certs.Dev do 2 | @moduledoc """ 3 | Generate fake certs (placeholders) for `HTTPS` endpoint and `APNS` service. 4 | 5 | Please be aware that `APNS` requires valid Apple Developer certificates, so it 6 | will not accept those fake certificates. Generated certificates may be used 7 | only with mock APNS service (like one provided by docker 8 | `mobify/apns-http2-mock-server`). 9 | """ 10 | @shortdoc "Generate fake certs (placeholders) for HTTPS endpoint and APNS" 11 | 12 | use Mix.Task 13 | 14 | # From: https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html 15 | @apns_topic_extn_id {1, 2, 840, 113_635, 100, 6, 3, 6} 16 | # Here we use the binary extension extracted from real APNS certificate. 17 | # It's much better for testing to use 18 | # the real extension instead of a generated one since, 19 | # the genertor would be based on reverse-engineered structure that may not be correct. 20 | # Also testing decoding on extesion encoded using the same encoder is kinda pointless. 21 | @apns_topic_extn_value <<48, 129, 133, 12, 26, 99, 111, 109, 46, 105, 110, 97, 22 | 107, 97, 110, 101, 116, 119, 111, 114, 107, 115, 46, 23 | 77, 97, 110, 103, 111, 115, 116, 97, 48, 5, 12, 3, 24 | 97, 112, 112, 12, 31, 99, 111, 109, 46, 105, 110, 97, 25 | 107, 97, 110, 101, 116, 119, 111, 114, 107, 115, 46, 26 | 77, 97, 110, 103, 111, 115, 116, 97, 46, 118, 111, 27 | 105, 112, 48, 6, 12, 4, 118, 111, 105, 112, 12, 39, 28 | 99, 111, 109, 46, 105, 110, 97, 107, 97, 110, 101, 29 | 116, 119, 111, 114, 107, 115, 46, 77, 97, 110, 103, 30 | 111, 115, 116, 97, 46, 99, 111, 109, 112, 108, 105, 31 | 99, 97, 116, 105, 111, 110, 48, 14, 12, 12, 99, 111, 32 | 109, 112, 108, 105, 99, 97, 116, 105, 111, 110>> 33 | 34 | @spec run(term) :: :ok 35 | def run(_) do 36 | maybe_gen_apns_token() 37 | maybe_gen_dev_apns() 38 | maybe_gen_prod_apns() 39 | maybe_gen_https() 40 | maybe_gen_test_client() 41 | end 42 | 43 | defp maybe_gen_dev_apns do 44 | maybe_gen_cert( 45 | "priv/apns/dev_cert.pem", 46 | "priv/apns/dev_key.pem", 47 | "sparrow-apns-dev" 48 | ) 49 | end 50 | 51 | defp maybe_gen_test_client do 52 | maybe_gen_cert( 53 | "priv/ssl/client_cert.pem", 54 | "priv/ssl/client_key.pem", 55 | "client" 56 | ) 57 | end 58 | 59 | defp maybe_gen_prod_apns do 60 | extensions = [ 61 | {@apns_topic_extn_id, @apns_topic_extn_value} 62 | ] 63 | 64 | maybe_gen_cert( 65 | "priv/apns/prod_cert.pem", 66 | "priv/apns/prod_key.pem", 67 | "sparrow-apns-prod", 68 | extensions 69 | ) 70 | end 71 | 72 | defp maybe_gen_https do 73 | maybe_gen_cert( 74 | "priv/ssl/fake_cert.pem", 75 | "priv/ssl/fake_key.pem", 76 | "sparrow" 77 | ) 78 | end 79 | 80 | defp maybe_gen_cert(cert_file, key_file, common_name, extensions \\ []) do 81 | if File.exists?(cert_file) and File.exists?(key_file) do 82 | :ok 83 | else 84 | gen_cert(cert_file, key_file, common_name, extensions) 85 | end 86 | end 87 | 88 | defp gen_cert(cert_file, key_file, common_name, extensions) do 89 | cert_dir = Path.dirname(cert_file) 90 | key_dir = Path.dirname(key_file) 91 | 92 | :ok = File.mkdir_p(cert_dir) 93 | :ok = File.mkdir_p(key_dir) 94 | 95 | ext_file = openssl_tmp_extfile(extensions) 96 | 97 | req_file = create_csr!(common_name, key_file, cert_file) 98 | :ok = sign_csr!(req_file, key_file, ext_file, cert_file) 99 | 100 | :ok = File.rm!(ext_file) 101 | :ok = File.rm!(req_file) 102 | end 103 | 104 | defp create_csr!(common_name, key_file, cert_file) do 105 | req_file = cert_file <> ".csr" 106 | 107 | {_, 0} = 108 | System.cmd("openssl", [ 109 | "req", 110 | "-new", 111 | "-nodes", 112 | "-days", 113 | "365", 114 | "-subj", 115 | "/C=PL/ST=ML/L=Krakow/CN=" <> common_name, 116 | "-newkey", 117 | "rsa:2048", 118 | "-keyout", 119 | key_file, 120 | "-out", 121 | req_file 122 | ]) 123 | 124 | req_file 125 | end 126 | 127 | defp sign_csr!(req_file, key_file, ext_file, cert_file) do 128 | {_, 0} = 129 | System.cmd("openssl", [ 130 | "x509", 131 | "-req", 132 | "-days", 133 | "365", 134 | "-in", 135 | req_file, 136 | "-signkey", 137 | key_file, 138 | "-extfile", 139 | ext_file, 140 | "-out", 141 | cert_file 142 | ]) 143 | 144 | :ok 145 | end 146 | 147 | defp openssl_tmp_extfile(extensions) do 148 | ext_file = Path.join("/tmp", UUID.uuid4()) 149 | # Make sure the file exists even if there are no extensions 150 | _ = File.touch(ext_file) 151 | 152 | for {ext_id, ext_bin} <- extensions do 153 | ext_id = extn_id_to_string(ext_id) 154 | ext_bin = Base.encode16(ext_bin) 155 | :ok = File.write!(ext_file, ~s"#{ext_id}=DER:#{ext_bin}\n", [:append]) 156 | end 157 | 158 | ext_file 159 | end 160 | 161 | defp extn_id_to_string(extn_id) do 162 | extn_id 163 | |> Tuple.to_list() 164 | |> Enum.join(".") 165 | end 166 | 167 | defp maybe_gen_apns_token do 168 | case System.cmd("openssl", [ 169 | "ecparam", 170 | "-name", 171 | "secp256r1", 172 | "-genkey", 173 | "-noout", 174 | "-out", 175 | "token.p8" 176 | ]) do 177 | {"", 0} -> 178 | :ok 179 | 180 | reason -> 181 | raise "Cannot generate p8 private key!!! Reason: #{inspect(reason)}" 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /lib/sparrow.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow do 2 | @moduledoc """ 3 | Sparrow is service providing ability to send push 4 | notification to `FCM` (Firebase Cloud Messaging) and/or 5 | `APNS` (Apple Push Notification Service). 6 | """ 7 | use Application 8 | 9 | def start(_type, _args) do 10 | raw_fcm_config = Application.get_env(:sparrow, :fcm) 11 | raw_apns_config = Application.get_env(:sparrow, :apns) 12 | start({raw_fcm_config, raw_apns_config}) 13 | end 14 | 15 | @spec start({Keyword.t(), Keyword.t()}) :: Supervisor.on_start() 16 | def start({raw_fcm_config, raw_apns_config}) do 17 | %{:enabled => is_enabled} = 18 | Application.get_env(:sparrow, Sparrow.PoolsWarden) 19 | 20 | children = 21 | is_enabled 22 | |> maybe_start_pools_warden() 23 | |> maybe_append({Sparrow.FCM.V1.Supervisor, raw_fcm_config}) 24 | |> maybe_append({Sparrow.APNS.Supervisor, raw_apns_config}) 25 | 26 | opts = [strategy: :one_for_one] 27 | Supervisor.start_link(children, opts) 28 | end 29 | 30 | @spec maybe_append([any], {any, nil | list}) :: [any] 31 | defp maybe_append(list, {_, nil}), do: list 32 | defp maybe_append(list, elem), do: list ++ [elem] 33 | 34 | defp maybe_start_pools_warden(true), do: [Sparrow.PoolsWarden] 35 | defp maybe_start_pools_warden(false), do: [] 36 | end 37 | -------------------------------------------------------------------------------- /lib/sparrow/api.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.API do 2 | @moduledoc """ 3 | Sparrow main API. 4 | """ 5 | use Sparrow.Telemetry.Timer 6 | require Logger 7 | 8 | @type notification :: 9 | Sparrow.FCM.V1.Notification.t() | Sparrow.APNS.Notification.t() 10 | @type sync_push_result :: 11 | Sparrow.FCM.V1.sync_push_result() | Sparrow.APNS.sync_push_result() 12 | @type pool_type :: Sparrow.PoolsWarden.pool_type() 13 | 14 | @doc """ 15 | Function to FCM and APNS push notifications. Pushes notifcation and waits for response. 16 | 17 | ## Arguments 18 | 19 | * `notification` - is `Sparrow.APNS.Notification` or `Sparrow.FCM.V1.Notification` struct 20 | * `tags` - tags allow to determine which `Sparrow.H2Worker.Pool` is chosen to push notification. 21 | Pool type must be the same as notification type (`:fcm` or `{:apns, :dev}` or `{:apns, :prod}`). 22 | Pool is chosen as first found from collection of pools that have ale tags included. 23 | * `opts` - 24 | * `:timeout` - works only if `:is_sync` is `true`, after set `:timeout` miliseconds request is timeouted 25 | * `:strategy` - strategy of choosing worker in pool strategy 26 | """ 27 | @timed event_tags: [:push, :api] 28 | @spec push(notification, [any], Keyword.t()) :: 29 | :ok | sync_push_result | {:error, :configuration_error} 30 | def push(notification, tags, opts) do 31 | pool_type = get_pool_type(notification) 32 | 33 | case Sparrow.PoolsWarden.choose_pool(pool_type, tags) do 34 | nil -> 35 | _ = 36 | Logger.error("Unable to select connection pool", 37 | what: :connection_pool, 38 | reason: :unable_to_find, 39 | pool_type: inspect(pool_type), 40 | tags: tags 41 | ) 42 | 43 | {:error, :configuration_error} 44 | 45 | pool -> 46 | do_push(pool, notification, opts) 47 | end 48 | end 49 | 50 | def push(notification, tags), do: push(notification, tags, []) 51 | def push(notification), do: push(notification, [], []) 52 | 53 | @doc """ 54 | Function to FCM and APNS push notifications. Pushes notifcation and returns `:ok` without waiting for response. 55 | 56 | ## Arguments 57 | 58 | * `notification` - is `Sparrow.APNS.Notification` or `Sparrow.FCM.V1.Notification` struct 59 | * `tags` - tags allow to determine which `Sparrow.H2Worker.Pool` is chosen to push notification. 60 | Pool type must be the same as notification type (`:fcm` or `{:apns, :dev}` or `{:apns, :prod}`). 61 | Pool is chosen as first found from collection of pools that have ale tags included. 62 | * `opts` - 63 | * `:strategy` - strategy of choosing worker in pool strategy 64 | """ 65 | @spec push_async(notification, [any], Keyword.t()) :: 66 | :ok | {:error, :configuration_error} 67 | def push_async(notification, tags \\ [], opts \\ []) do 68 | push(notification, tags, [{:is_sync, false} | opts]) 69 | end 70 | 71 | @spec get_pool_type(notification) :: pool_type 72 | defp get_pool_type(notification = %Sparrow.APNS.Notification{}) do 73 | {:apns, notification.type} 74 | end 75 | 76 | defp get_pool_type(_notification = %Sparrow.FCM.V1.Notification{}) do 77 | :fcm 78 | end 79 | 80 | @spec do_push(pool_name :: atom, notification, Keyword.t()) :: 81 | sync_push_result | :ok 82 | defp do_push(pool_name, notification = %Sparrow.APNS.Notification{}, opts) do 83 | Sparrow.APNS.push(pool_name, notification, opts) 84 | end 85 | 86 | defp do_push(pool_name, notification = %Sparrow.FCM.V1.Notification{}, opts) do 87 | Sparrow.FCM.V1.push(pool_name, notification, opts) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/sparrow/apns/errors.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.APNS.Errors do 2 | @moduledoc false 3 | 4 | @spec get_error_description(atom) :: String.t() 5 | def get_error_description(:BadCollapseId), 6 | do: "The collapse identifier exceeds the maximum allowed size." 7 | 8 | def get_error_description(:BadDeviceToken), 9 | do: 10 | "The specified device token was bad. 11 | Verify that the request contains a valid token and that the token matches the environment." 12 | 13 | def get_error_description(:BadExpirationDate), 14 | do: "The apns-expiration value is bad." 15 | 16 | def get_error_description(:BadMessageId), 17 | do: "The apns-id value is bad." 18 | 19 | def get_error_description(:BadPriority), 20 | do: "The apns-priority value is bad." 21 | 22 | def get_error_description(:BadTopic), do: "The apns-topic was invalid." 23 | 24 | def get_error_description(:DeviceTokenNotForTopic), 25 | do: "The device token does not match the specified topic." 26 | 27 | def get_error_description(:DuplicateHeaders), 28 | do: "One or more headers were repeated." 29 | 30 | def get_error_description(:IdleTimeout), do: "Idle time out." 31 | 32 | def get_error_description(:MissingDeviceToken), 33 | do: "The device token is not specified in the request :path. 34 | Verify that the :path header contains the device token." 35 | 36 | def get_error_description(:MissingTopic), 37 | do: 38 | "The apns-topic header of the request was not specified and was required. 39 | The apns-topic header is mandatory when the client is connected 40 | using a certificate that supports multiple topics." 41 | 42 | def get_error_description(:PayloadEmpty), 43 | do: "The message payload was empty." 44 | 45 | def get_error_description(:TopicDisallowed), 46 | do: "Pushing to this topic is not allowed." 47 | 48 | def get_error_description(:BadCertificate), 49 | do: "The certificate was bad." 50 | 51 | def get_error_description(:BadCertificateEnvironment), 52 | do: "The client certificate was for the wrong environment." 53 | 54 | def get_error_description(:ExpiredProviderToken), 55 | do: "The provider token is stale and a new token should be generated." 56 | 57 | def get_error_description(:Forbidden), 58 | do: "The specified action is not allowed." 59 | 60 | def get_error_description(:InvalidProviderToken), 61 | do: 62 | "The provider token is not valid or the token signature could not be verified." 63 | 64 | def get_error_description(:MissingProviderToken), 65 | do: "No provider certificate was used to connect to APNs and Authorization 66 | header was missing or no provider token was specified." 67 | 68 | def get_error_description(:BadPath), 69 | do: "The request contained a bad :path value." 70 | 71 | def get_error_description(:MethodNotAllowed), 72 | do: "The specified :method was not POST." 73 | 74 | def get_error_description(:Unregistered), 75 | do: "The device token is inactive for the specified topic." 76 | 77 | def get_error_description(:PayloadTooLarge), 78 | do: 79 | "The message payload was too large. 80 | See Creating the Remote Notification Payload for details on maximum payload size." 81 | 82 | def get_error_description(:TooManyProviderTokenUpdates), 83 | do: "The provider token is being updated too often." 84 | 85 | def get_error_description(:TooManyRequests), 86 | do: "Too many requests were made consecutively to the same device token." 87 | 88 | def get_error_description(:InternalServerError), 89 | do: "An internal server error occurred." 90 | 91 | def get_error_description(:ServiceUnavailable), 92 | do: "The service is unavailable." 93 | 94 | def get_error_description(:Shutdown), do: "The server is shutting down." 95 | 96 | def get_error_description(error), 97 | do: "Unmatched error = #{inspect(error)}" 98 | end 99 | -------------------------------------------------------------------------------- /lib/sparrow/apns/notification/sound.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.APNS.Notification.Sound do 2 | @moduledoc """ 3 | Helper to build sound dictionary for APNS notification. 4 | 5 | ## Example 6 | 7 | alias Sparrow.APNS.Notification.Sound 8 | sound = 9 | "chirp" 10 | |> Sound.new() 11 | |> Sound.add_critical() 12 | |> Sound.add_volume(0.07) 13 | """ 14 | 15 | @doc """ 16 | Method to create new sound. 17 | """ 18 | @spec new(String.t()) :: map 19 | def new(name) do 20 | %{ 21 | "name" => name 22 | } 23 | end 24 | 25 | @doc """ 26 | The critical alert flag. Set to 1 to enable the critical alert. 27 | """ 28 | @spec add_critical(map) :: map 29 | def add_critical(sound) do 30 | Map.put(sound, "critical", 1) 31 | end 32 | 33 | @doc """ 34 | The volume for the critical alert’s sound. Set this to a value between 0.0 (silent) and 1.0 (full volume). 35 | """ 36 | @spec add_volume(map, float) :: map 37 | def add_volume(sound, volume) do 38 | Map.put(sound, "volume", volume) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/sparrow/apns/pool/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.APNS.Pool.Supervisor do 2 | @moduledoc """ 3 | Supervises a single APNS workers pool. 4 | """ 5 | use Supervisor 6 | 7 | @apns_dev_endpoint "api.development.push.apple.com" 8 | @apns_prod_endpoint "api.push.apple.com" 9 | @apns_endpoint [{:dev, @apns_dev_endpoint}, {:prod, @apns_prod_endpoint}] 10 | 11 | @spec start_link(Keyword.t()) :: Supervisor.on_start() 12 | def start_link(arg) do 13 | Supervisor.start_link(__MODULE__, arg) 14 | end 15 | 16 | @spec init(Keyword.t()) :: 17 | {:ok, {Supervisor.sup_flags(), [Supervisor.child_spec()]}} 18 | def init(raw_apns_config) do 19 | dev_raw_configs = Keyword.get(raw_apns_config, :dev, []) 20 | prod_raw_configs = Keyword.get(raw_apns_config, :prod, []) 21 | 22 | pool_configs = 23 | (get_apns_pool_configs(dev_raw_configs, :dev) ++ 24 | get_apns_pool_configs(prod_raw_configs, :prod)) 25 | |> Enum.with_index() 26 | 27 | children = 28 | for {{{pool_config, pool_tags}, pool_type}, index} <- pool_configs do 29 | id = String.to_atom("Sparrow.APNS.Pool.ID." <> Integer.to_string(index)) 30 | 31 | %{ 32 | id: id, 33 | start: 34 | {Sparrow.H2Worker.Pool, :start_link, 35 | [pool_config, pool_type, pool_tags]} 36 | } 37 | end 38 | 39 | Supervisor.init(children, strategy: :one_for_one) 40 | end 41 | 42 | @spec get_apns_pool_configs(Keyword.t(), :dev | :prod) :: [ 43 | {{Sparrow.H2Worker.Pool.Config.t(), [atom]}, :dev | :prod} 44 | ] 45 | defp get_apns_pool_configs(raw_pool_configs, pool_type) do 46 | for raw_pool_config <- raw_pool_configs do 47 | {get_apns_pool_config(raw_pool_config, pool_type), {:apns, pool_type}} 48 | end 49 | end 50 | 51 | @spec get_apns_pool_config(Keyword.t(), :dev | :prod) :: 52 | {Sparrow.H2Worker.Pool.Config.t(), [atom]} 53 | defp get_apns_pool_config(raw_pool_config, pool_type) do 54 | port = Keyword.get(raw_pool_config, :port, 443) 55 | 56 | tls_opts = 57 | Keyword.get(raw_pool_config, :tls_opts, setup_default_tls_options()) 58 | 59 | ping_interval = Keyword.get(raw_pool_config, :ping_interval, 5000) 60 | reconnection_attempts = Keyword.get(raw_pool_config, :reconnect_attempts, 3) 61 | 62 | auth = 63 | case Keyword.get(raw_pool_config, :auth_type) do 64 | :token_based -> 65 | raw_pool_config 66 | |> Keyword.get(:token_id) 67 | |> Sparrow.APNS.get_token_based_authentication() 68 | 69 | :certificate_based -> 70 | cert = Keyword.get(raw_pool_config, :cert) 71 | key = Keyword.get(raw_pool_config, :key) 72 | Sparrow.H2Worker.Authentication.CertificateBased.new(cert, key) 73 | end 74 | 75 | pool_name = Keyword.get(raw_pool_config, :pool_name) 76 | pool_size = Keyword.get(raw_pool_config, :worker_num, 3) 77 | pool_opts = Keyword.get(raw_pool_config, :raw_opts, []) 78 | pool_tags = Keyword.get(raw_pool_config, :tags, []) 79 | 80 | uri = Keyword.get(raw_pool_config, :endpoint, @apns_endpoint[pool_type]) 81 | 82 | worker_config = 83 | Sparrow.H2Worker.Config.new(%{ 84 | domain: uri, 85 | port: port, 86 | authentication: auth, 87 | tls_options: tls_opts, 88 | ping_interval: ping_interval, 89 | reconnect_attempts: reconnection_attempts 90 | }) 91 | 92 | pool_config = 93 | Sparrow.H2Worker.Pool.Config.new( 94 | worker_config, 95 | pool_name, 96 | pool_size, 97 | pool_opts 98 | ) 99 | 100 | {pool_config, pool_tags} 101 | end 102 | 103 | defp setup_default_tls_options do 104 | cacerts = :certifi.cacerts() 105 | 106 | [ 107 | {:verify, :verify_peer}, 108 | {:depth, 99}, 109 | {:cacerts, cacerts} 110 | ] 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/sparrow/apns/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.APNS.Supervisor do 2 | @moduledoc """ 3 | Main APNS supervisor. 4 | Supervises APNS tokens bearer and pool supervisors. 5 | """ 6 | use Supervisor 7 | 8 | @spec start_link(Keyword.t()) :: Supervisor.on_start() 9 | def start_link(arg) do 10 | Supervisor.start_link(__MODULE__, arg) 11 | end 12 | 13 | @spec init(Keyword.t()) :: 14 | {:ok, {Supervisor.sup_flags(), [Supervisor.child_spec()]}} 15 | def init(raw_apns_config) do 16 | tokens = get_apns_tokens(raw_apns_config) 17 | 18 | children = [ 19 | {Sparrow.APNS.Pool.Supervisor, raw_apns_config}, 20 | {Sparrow.APNS.TokenBearer, tokens} 21 | ] 22 | 23 | Supervisor.init(children, strategy: :one_for_all) 24 | end 25 | 26 | @spec get_apns_tokens(Keyword.t()) :: %{ 27 | required(atom) => Sparrow.APNS.Token.t() 28 | } 29 | defp get_apns_tokens(raw_apns_config) do 30 | token_configs = Keyword.get(raw_apns_config, :tokens, []) 31 | 32 | for token_config <- token_configs, into: %{} do 33 | get_apns_token(token_config) 34 | end 35 | end 36 | 37 | @spec get_apns_token(Keyword.t()) :: {atom, Sparrow.APNS.Token.t()} 38 | defp get_apns_token(token_config) do 39 | token_id = Keyword.get(token_config, :token_id) 40 | team_id = Keyword.get(token_config, :team_id) 41 | key_id = Keyword.get(token_config, :key_id) 42 | p8_file_path = Keyword.get(token_config, :p8_file_path) 43 | {token_id, Sparrow.APNS.Token.new(key_id, team_id, p8_file_path)} 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/sparrow/apns/token.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.APNS.Token do 2 | @moduledoc """ 3 | Struct to init argument for APNS token bearer. 4 | """ 5 | 6 | @type t :: %__MODULE__{ 7 | key_id: String.t(), 8 | team_id: String.t(), 9 | p8_file_path: String.t() 10 | } 11 | 12 | defstruct [ 13 | :key_id, 14 | :team_id, 15 | :p8_file_path 16 | ] 17 | 18 | @doc """ 19 | Creates new token. 20 | 21 | ## Arguments 22 | 23 | * `key_id` - APNS key ID 24 | * `team_id` - 10-character Team ID you use for developing your company’s apps. 25 | * `p8_file_path` - file path to APNs authentication token signing key to generate the tokens 26 | 27 | ## How to obtain key (content of file under p8_file_path) and `key_id`? 28 | 29 | Read: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_token_based_connection_to_apns 30 | 31 | ## How to obtain `team_id`? 32 | 33 | Read: https://www.mobiloud.com/help/knowledge-base/ios-app-transfer/ 34 | 35 | ## How often should I refresh token? 36 | 37 | Read: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_token_based_connection_to_apns 38 | Section: Refresh Your Token Regularly 39 | """ 40 | @spec new(String.t(), String.t(), String.t()) :: t 41 | def new( 42 | key_id, 43 | team_id, 44 | p8_file_path 45 | ) do 46 | %__MODULE__{ 47 | key_id: key_id, 48 | team_id: team_id, 49 | p8_file_path: p8_file_path 50 | } 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/sparrow/apns/token_bearer.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.APNS.TokenBearer do 2 | @moduledoc """ 3 | Module providing APNS token. 4 | """ 5 | 6 | use GenServer 7 | require Logger 8 | 9 | @type bearer_token :: Joken.bearer_token() 10 | @type claims :: Joken.claims() 11 | 12 | @tab_name :sparrow_apns_token_bearer 13 | @apns_jwt_alg "ES256" 14 | @refresh_token_time :timer.minutes(50) 15 | 16 | @doc """ 17 | Returns APNS token for token based authentication. 18 | """ 19 | @spec get_token(atom) :: String.t() | nil 20 | def get_token(token_id) do 21 | case :ets.lookup(@tab_name, token_id) do 22 | [{_, token}] -> token 23 | _ -> nil 24 | end 25 | end 26 | 27 | @spec start_link( 28 | %{required(atom) => Sparrow.APNS.Token.t()} 29 | | {%{required(atom) => Sparrow.APNS.Token.t()}, pos_integer} 30 | ) :: GenServer.on_start() 31 | def start_link(tokens) do 32 | GenServer.start_link(__MODULE__, tokens, name: __MODULE__) 33 | end 34 | 35 | @spec init( 36 | %{required(atom) => Sparrow.APNS.Token.t()} 37 | | {%{required(atom) => Sparrow.APNS.Token.t()}, pos_integer} 38 | ) :: {:ok, Sparrow.APNS.TokenBearer.State.t()} 39 | 40 | def init({tokens, refresh_token_time}) do 41 | state = Sparrow.APNS.TokenBearer.State.new(tokens, refresh_token_time) 42 | @tab_name = :ets.new(@tab_name, [:set, :protected, :named_table]) 43 | update_tokens(state) 44 | 45 | _ = 46 | Logger.info("Starting TokenBearer", 47 | worker: :apns_token_bearer, 48 | what: :init, 49 | result: :success 50 | ) 51 | 52 | :telemetry.execute([:sparrow, :apns, :token_bearer, :init], %{}, %{}) 53 | 54 | {:ok, state} 55 | end 56 | 57 | def init(tokens) do 58 | state = Sparrow.APNS.TokenBearer.State.new(tokens, @refresh_token_time) 59 | @tab_name = :ets.new(@tab_name, [:set, :protected, :named_table]) 60 | update_tokens(state) 61 | 62 | _ = 63 | Logger.info("Starting TokenBearer", 64 | worker: :apns_token_bearer, 65 | what: :init, 66 | result: :success 67 | ) 68 | 69 | :telemetry.execute([:sparrow, :apns, :token_bearer, :init], %{}, %{}) 70 | 71 | {:ok, state} 72 | end 73 | 74 | @spec terminate(any, Sparrow.APNS.TokenBearer.State.t()) :: :ok 75 | def terminate(reason, _state) do 76 | ets_del = :ets.delete(@tab_name) 77 | 78 | _ = 79 | Logger.info("Shutting down TokenBearer", 80 | worker: :apns_token_bearer, 81 | what: :terminate, 82 | reason: inspect(reason), 83 | ets_delate_result: inspect(ets_del) 84 | ) 85 | 86 | :telemetry.execute([:sparrow, :apns, :token_bearer, :terminate], %{}, %{}) 87 | end 88 | 89 | @spec handle_info(:update_tokens | any, Sparrow.APNS.TokenBearer.State.t()) :: 90 | {:noreply, Sparrow.APNS.TokenBearer.State.t()} 91 | def handle_info(:update_tokens, state) do 92 | update_tokens(state) 93 | 94 | _ = 95 | Logger.debug("Updating APNS token", 96 | worker: :apns_token_bearer, 97 | what: :token_update 98 | ) 99 | 100 | {:noreply, state} 101 | end 102 | 103 | def handle_info(unknown, state) do 104 | _ = 105 | Logger.warning("Unknown message", 106 | worker: :apns_token_bearer, 107 | what: :unknown_message, 108 | message: inspect(unknown) 109 | ) 110 | 111 | {:noreply, state} 112 | end 113 | 114 | @spec update_tokens(Sparrow.APNS.TokenBearer.State.t()) :: :ok 115 | defp update_tokens(state) do 116 | schedule_message_after(state.update_token_after, :update_tokens) 117 | set_new_tokens(state) 118 | end 119 | 120 | @spec set_new_tokens(Sparrow.APNS.TokenBearer.State.t()) :: :ok 121 | defp set_new_tokens(state) do 122 | state.tokens 123 | |> Map.keys() 124 | |> Enum.each(fn key -> 125 | token_struct = Map.get(state.tokens, key) 126 | 127 | {:ok, token, _} = 128 | new_jwt_token( 129 | token_struct.key_id, 130 | token_struct.team_id, 131 | token_struct.p8_file_path 132 | ) 133 | 134 | :ets.insert(@tab_name, {key, token}) 135 | end) 136 | end 137 | 138 | @spec new_jwt_token(String.t(), String.t(), String.t()) :: 139 | {:error, reason :: any} | {:ok, bearer_token, claims} 140 | defp new_jwt_token(key_id, team_id, p8_file_path) do 141 | signer = new_signer(@apns_jwt_alg, key_id, p8_file_path) 142 | 143 | %{} 144 | |> Map.put("iat", %Joken.Claim{ 145 | generate: fn -> Joken.CurrentTime.OS.current_time() end 146 | }) 147 | |> Map.put("iss", %Joken.Claim{ 148 | generate: fn -> team_id end 149 | }) 150 | |> Joken.generate_and_sign(%{}, signer) 151 | end 152 | 153 | @spec new_signer(String.t(), String.t(), String.t()) :: Joken.Signer.t() 154 | defp new_signer(alg, key_id, p8_file_path) do 155 | %Joken.Signer{ 156 | alg: alg, 157 | jws: JOSE.JWS.from_map(%{"alg" => alg, "typ" => "JWT", "kid" => key_id}), 158 | jwk: JOSE.JWK.from_pem_file(p8_file_path) 159 | } 160 | end 161 | 162 | @spec schedule_message_after(pos_integer, :update_tokens) :: reference 163 | defp schedule_message_after(time, message) do 164 | :erlang.send_after(time, self(), message) 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/sparrow/apns/token_bearer/state.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.APNS.TokenBearer.State do 2 | @moduledoc false 3 | 4 | @type t :: %__MODULE__{ 5 | tokens: %{required(atom) => Sparrow.APNS.Token.t()}, 6 | update_token_after: pos_integer 7 | } 8 | 9 | defstruct [ 10 | :tokens, 11 | :update_token_after 12 | ] 13 | 14 | @spec new(%{required(atom) => Sparrow.APNS.Token.t()}, pos_integer) :: t 15 | def new(tokens, update_token_after) do 16 | %__MODULE__{ 17 | tokens: tokens, 18 | update_token_after: update_token_after 19 | } 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/sparrow/fcm/v1/android/notification.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.Android.Notification do 2 | @moduledoc false 3 | @type key :: 4 | :title 5 | | :body 6 | | :icon 7 | | :color 8 | | :sound 9 | | :tag 10 | | :click_action 11 | | :body_loc_key 12 | | :"body_loc_args[]" 13 | | :title_loc_key 14 | | :"title_loc_args[]" 15 | @type t :: %__MODULE__{fields: [{key, value :: String.t()}]} 16 | 17 | defstruct [:fields] 18 | 19 | @spec new() :: t 20 | def new do 21 | %__MODULE__{fields: []} 22 | end 23 | 24 | @spec add(__MODULE__.t(), key, value :: String.t()) :: __MODULE__.t() 25 | def add(notification, key, value) do 26 | %__MODULE__{fields: [{key, value} | notification.fields]} 27 | end 28 | 29 | @spec to_map(__MODULE__.t()) :: map 30 | def to_map(android_notification) do 31 | Map.new(android_notification.fields) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/sparrow/fcm/v1/apns.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.APNS do 2 | @moduledoc """ 3 | Struct reflecting FCM object(ApnsConfig). 4 | See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=1#ApnsConfig 5 | FCM wrapper for `Sparrow.APNS.Notification`. 6 | """ 7 | 8 | @type token_getter :: (-> {String.t(), String.t()}) | nil 9 | @type t :: %__MODULE__{ 10 | notification: Sparrow.APNS.Notification.t(), 11 | token_getter: token_getter 12 | } 13 | defstruct [ 14 | :notification, 15 | :token_getter 16 | ] 17 | 18 | @doc """ 19 | Function to create new `Sparrow.FCM.V1.APNS`. 20 | 21 | ## Arguments 22 | 23 | * `notification` - APNS notification. See `Sparrow.APNS.Notification`. 24 | * `token_getter` - function returning authrization header. See `Sparrow.APNS.TokenBearer.get_token/1`. 25 | """ 26 | @spec new(Sparrow.APNS.Notification.t(), token_getter) :: 27 | Sparrow.FCM.V1.APNS.t() 28 | def new(notification, token_getter \\ nil) do 29 | %__MODULE__{ 30 | notification: notification, 31 | token_getter: token_getter 32 | } 33 | end 34 | 35 | @doc """ 36 | Function to transfer `Sparrow.FCM.V1.APNS` to map. 37 | """ 38 | @spec to_map(t) :: map 39 | def to_map(apns) do 40 | auth_header = 41 | case apns.token_getter do 42 | nil -> [] 43 | token_getter -> [token_getter.()] 44 | end 45 | 46 | %{ 47 | :headers => Map.new(auth_header ++ apns.notification.headers), 48 | :payload => Sparrow.APNS.make_body(apns.notification) 49 | } 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/sparrow/fcm/v1/notification.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.Notification do 2 | @moduledoc """ 3 | Struct representing single FCM.V1 notification. 4 | 5 | For details on the FCM.V1 notification payload structure see the following links: 6 | * https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages 7 | """ 8 | 9 | alias Sparrow.H2Worker.Request 10 | 11 | @type target_type :: :token | :topic | :condition 12 | @type android :: nil | Sparrow.FCM.V1.Android.t() 13 | @type webpush :: nil | Sparrow.FCM.V1.Webpush.t() 14 | @type apns :: nil | Sparrow.FCM.V1.APNS.t() 15 | @type headers :: Request.headers() 16 | @type t :: %__MODULE__{ 17 | project_id: String.t() | nil, 18 | headers: headers, 19 | data: map, 20 | title: String.t() | nil, 21 | body: String.t() | nil, 22 | android: android, 23 | webpush: webpush, 24 | apns: apns, 25 | target: {target_type, String.t()} 26 | } 27 | 28 | @headers [{"content-type", "application/json"}] 29 | 30 | defstruct [ 31 | :project_id, 32 | :headers, 33 | :data, 34 | :title, 35 | :body, 36 | :android, 37 | :webpush, 38 | :apns, 39 | :target, 40 | :target_type 41 | ] 42 | 43 | @doc """ 44 | Creates new notification. 45 | 46 | ## Arguments 47 | 48 | * `target` - Target to send a message to. 49 | * `target_type` can be only one of the following: 50 | * `:token` - Registration token to send a message to. 51 | * `:topic` - Topic name to send a message to, e.g. "weather". Note: "/topics/" prefix should not be provided. 52 | * `:condition` - Condition to send a message to, e.g. "'foo' in topics && 'bar' in topics". 53 | * `title` - The notification's title. 54 | * `body` - The notification's body text. 55 | * `data` - An object containing a list of `"key"`: value pairs. Example: `{ "name": "wrench", "mass": "1.3kg", "count": "3" }`. 56 | """ 57 | @spec new( 58 | target_type, 59 | String.t(), 60 | String.t() | nil, 61 | String.t() | nil, 62 | map 63 | ) :: t 64 | def new( 65 | target_type, 66 | target, 67 | title \\ nil, 68 | body \\ nil, 69 | data \\ %{} 70 | ) do 71 | %__MODULE__{ 72 | headers: @headers, 73 | data: data, 74 | title: title, 75 | body: body, 76 | android: nil, 77 | webpush: nil, 78 | apns: nil, 79 | target: target, 80 | target_type: target_type 81 | } 82 | end 83 | 84 | @doc """ 85 | Add `Sparrow.FCM.V1.Android` to `Sparrow.FCM.V1.Notification`. 86 | """ 87 | @spec add_android(t, android) :: t 88 | def add_android(notification, config) do 89 | %{notification | android: config} 90 | end 91 | 92 | @doc """ 93 | Add `Sparrow.FCM.V1.Webpush` to `Sparrow.FCM.V1.Notification`. 94 | """ 95 | @spec add_webpush(t, webpush) :: t 96 | def add_webpush(notification, config) do 97 | %{notification | webpush: config} 98 | end 99 | 100 | @doc """ 101 | Add `Sparrow.FCM.V1.APNS` to `Sparrow.FCM.V1.Notification`. 102 | """ 103 | @spec add_apns(t, apns) :: t 104 | def add_apns(notification, config) do 105 | %{notification | apns: config} 106 | end 107 | 108 | @doc """ 109 | Add `project_id` to `Sparrow.FCM.V1.Notification`. 110 | WARNING This function is called automatically when pushing notification. 111 | There is NO need to cally it manually when creating notiifcation. 112 | """ 113 | @spec add_project_id(t, project_id :: String.t()) :: t 114 | def add_project_id(notification, project_id) do 115 | %{notification | project_id: project_id} 116 | end 117 | 118 | @spec normalize(t) :: {:ok, t} | {:error, :invalid_notification} 119 | def normalize(notification) do 120 | with {:ok, notification} <- do_normalize(notification), 121 | {:ok, android} <- 122 | Sparrow.FCM.V1.Android.normalize(notification.android), 123 | {:ok, webpush} <- 124 | Sparrow.FCM.V1.Webpush.normalize(notification.webpush) do 125 | {:ok, Map.merge(notification, %{android: android, webpush: webpush})} 126 | end 127 | end 128 | 129 | @spec do_normalize(t) :: {:ok, t} | {:error, :invalid_notification} 130 | def do_normalize(notification) do 131 | data = Enum.map(notification.data, &normalize_value/1) 132 | 133 | case Enum.all?(data) do 134 | false -> 135 | {:error, :invalid_notification} 136 | 137 | true -> 138 | {:ok, %{notification | data: Map.new(data)}} 139 | end 140 | end 141 | 142 | defp normalize_value({k, v}) do 143 | {k, to_string(v)} 144 | rescue 145 | Protocol.UndefinedError -> false 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/sparrow/fcm/v1/pool/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.Pool.Supervisor do 2 | @moduledoc """ 3 | Supervises a single FCM workers pool. 4 | """ 5 | use Supervisor 6 | 7 | @fcm_default_endpoint "fcm.googleapis.com" 8 | @account_key "client_email" 9 | 10 | @spec start_link(Keyword.t()) :: Supervisor.on_start() 11 | def start_link(arg) do 12 | Supervisor.start_link(__MODULE__, arg) 13 | end 14 | 15 | @spec init(Keyword.t()) :: 16 | {:ok, {Supervisor.sup_flags(), [Supervisor.child_spec()]}} 17 | def init(raw_config) do 18 | pool_configs = 19 | Enum.map(raw_config, fn single_config -> 20 | {single_config[:path_to_json], get_fcm_pool_config(single_config)} 21 | end) 22 | 23 | for {path_to_json, {pool_config, _pool_tags}} <- pool_configs do 24 | Sparrow.FCM.V1.ProjectIdBearer.add_project_id( 25 | path_to_json, 26 | pool_config.pool_name 27 | ) 28 | end 29 | 30 | children = 31 | for {{_json, {pool_config, pool_tags}}, index} <- 32 | Enum.with_index(pool_configs) do 33 | id = String.to_atom("Sparrow.Fcm.Pool.ID.#{index}") 34 | 35 | %{ 36 | id: id, 37 | start: 38 | {Sparrow.H2Worker.Pool, :start_link, [pool_config, :fcm, pool_tags]} 39 | } 40 | end 41 | 42 | Supervisor.init(children, strategy: :one_for_one) 43 | end 44 | 45 | @spec get_fcm_pool_config(Keyword.t()) :: 46 | {Sparrow.H2Worker.Pool.Config.t(), [atom]} 47 | defp get_fcm_pool_config(raw_pool_config) do 48 | uri = Keyword.get(raw_pool_config, :endpoint, @fcm_default_endpoint) 49 | port = Keyword.get(raw_pool_config, :port, 443) 50 | 51 | tls_opts = 52 | Keyword.get(raw_pool_config, :tls_opts, setup_default_tls_options()) 53 | 54 | ping_interval = Keyword.get(raw_pool_config, :ping_interval, 5000) 55 | reconnection_attempts = Keyword.get(raw_pool_config, :reconnect_attempts, 3) 56 | 57 | pool_tags = Keyword.get(raw_pool_config, :tags, []) 58 | pool_name = Keyword.get(raw_pool_config, :pool_name) 59 | pool_size = Keyword.get(raw_pool_config, :worker_num, 3) 60 | pool_opts = Keyword.get(raw_pool_config, :raw_opts, []) 61 | 62 | account = 63 | raw_pool_config 64 | |> Keyword.get(:path_to_json) 65 | |> File.read!() 66 | |> Jason.decode!() 67 | |> Map.fetch!(@account_key) 68 | 69 | config = 70 | account 71 | |> Sparrow.FCM.V1.get_token_based_authentication() 72 | |> Sparrow.FCM.V1.get_h2worker_config( 73 | uri, 74 | port, 75 | tls_opts, 76 | ping_interval, 77 | reconnection_attempts 78 | ) 79 | |> Sparrow.H2Worker.Pool.Config.new(pool_name, pool_size, pool_opts) 80 | 81 | {config, pool_tags} 82 | end 83 | 84 | defp setup_default_tls_options do 85 | cacerts = :certifi.cacerts() 86 | 87 | [ 88 | {:verify, :verify_peer}, 89 | {:depth, 99}, 90 | {:cacerts, cacerts} 91 | ] 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/sparrow/fcm/v1/project_ids_bearer.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.ProjectIdBearer do 2 | @moduledoc """ 3 | Module providing FCM project id automaticly. 4 | """ 5 | require Logger 6 | use GenServer 7 | @tab_name :fcm_project_ids 8 | 9 | @spec get_project_id(atom) :: String.t() | nil 10 | def get_project_id(h2_worker_pool) do 11 | case :ets.lookup(@tab_name, h2_worker_pool) do 12 | [{_, project_id}] -> project_id 13 | _ -> nil 14 | end 15 | end 16 | 17 | @spec add_project_id(Path.t(), atom) :: true 18 | def add_project_id(google_json_path, h2_worker_pool_name) do 19 | GenServer.call( 20 | __MODULE__, 21 | {:add_project_id, google_json_path, h2_worker_pool_name} 22 | ) 23 | end 24 | 25 | def handle_call( 26 | {:add_project_id, google_json_path, h2_worker_pool_name}, 27 | _from, 28 | _state 29 | ) do 30 | json = File.read!(google_json_path) 31 | 32 | _ = 33 | Logger.debug("Reading FCM config file", 34 | worker: :fcm_project_id_bearer, 35 | what: :read_json_config, 36 | result: :success 37 | ) 38 | 39 | project_id = 40 | json 41 | |> Jason.decode!() 42 | |> Map.get("project_id") 43 | 44 | _ = 45 | Logger.debug("Extracting FCM project ID from config", 46 | worker: :fcm_project_id_bearer, 47 | what: :extract_project_id_from_json, 48 | project_id: inspect(project_id) 49 | ) 50 | 51 | :ets.insert(@tab_name, {h2_worker_pool_name, project_id}) 52 | {:reply, :ok, :ok} 53 | end 54 | 55 | @spec start_link :: GenServer.on_start() 56 | def start_link do 57 | GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 58 | end 59 | 60 | @spec start_link(any()) :: GenServer.on_start() 61 | def start_link(_) do 62 | GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 63 | end 64 | 65 | @spec init(any()) :: {:ok, :ok} 66 | def init(_) do 67 | @tab_name = :ets.new(@tab_name, [:set, :protected, :named_table]) 68 | 69 | _ = 70 | Logger.info("Starting ProjectIdBearer", 71 | worker: :fcm_project_id_bearer, 72 | what: :init, 73 | result: :success 74 | ) 75 | 76 | :telemetry.execute([:sparrow, :fcm, :project_id_bearer, :init], %{}, %{}) 77 | 78 | {:ok, :ok} 79 | end 80 | 81 | @spec terminate(any, any) :: :ok 82 | def terminate(reason, _state) do 83 | _ = 84 | Logger.info("Shutting down ProjectIdBearer", 85 | worker: :fcm_project_id_bearer, 86 | what: :terminate, 87 | reason: inspect(reason) 88 | ) 89 | 90 | :telemetry.execute( 91 | [:sparrow, :fcm, :project_id_bearer, :terminate], 92 | %{}, 93 | %{} 94 | ) 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/sparrow/fcm/v1/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.Supervisor do 2 | @moduledoc """ 3 | Main FCM supervisor. 4 | Supervises FCM token bearer and pool supervisor. 5 | """ 6 | use Supervisor 7 | 8 | @spec start_link([Keyword.t()]) :: Supervisor.on_start() 9 | def start_link(arg) do 10 | Supervisor.start_link(__MODULE__, arg) 11 | end 12 | 13 | @spec init([Keyword.t()]) :: 14 | {:ok, {Supervisor.sup_flags(), [Supervisor.child_spec()]}} 15 | def init(raw_fcm_config) do 16 | children = [ 17 | %{ 18 | id: Sparrow.FCM.V1.TokenBearer, 19 | start: {Sparrow.FCM.V1.TokenBearer, :start_link, [raw_fcm_config]} 20 | }, 21 | Sparrow.FCM.V1.ProjectIdBearer, 22 | {Sparrow.FCM.V1.Pool.Supervisor, raw_fcm_config} 23 | ] 24 | 25 | Supervisor.init(children, strategy: :one_for_one) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/sparrow/fcm/v1/token_bearer.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.TokenBearer do 2 | @moduledoc """ 3 | Module providing FCM token. 4 | """ 5 | 6 | require Logger 7 | 8 | @spec get_token(String.t()) :: String.t() | nil 9 | def get_token(account) do 10 | {:ok, token_map} = Goth.fetch(account) 11 | 12 | _ = 13 | Logger.debug("Fetching FCM token", 14 | worker: :fcm_token_bearer, 15 | what: :get_token, 16 | result: :success 17 | ) 18 | 19 | Map.get(token_map, :token) 20 | end 21 | 22 | @spec start_link(Path.t()) :: GenServer.on_start() 23 | def start_link(raw_fcm_config) do 24 | scopes = ["https://www.googleapis.com/auth/firebase.messaging"] 25 | 26 | opts = [ 27 | {:scopes, scopes} 28 | | maybe_url() 29 | ] 30 | 31 | children = 32 | raw_fcm_config 33 | |> Enum.map(&decode_config/1) 34 | |> Enum.map(fn %{"client_email" => account} = json -> 35 | Supervisor.child_spec( 36 | {Goth, name: account, source: {:service_account, json, opts}}, 37 | id: account 38 | ) 39 | end) 40 | 41 | _ = 42 | Logger.debug("Starting FCM TokenBearer", 43 | worker: :fcm_token_bearer, 44 | what: :start_link, 45 | result: :success 46 | ) 47 | 48 | Supervisor.start_link(children, strategy: :one_for_one) 49 | end 50 | 51 | defp decode_config(config) do 52 | config[:path_to_json] 53 | |> File.read!() 54 | |> Jason.decode!() 55 | end 56 | 57 | defp maybe_url do 58 | case Application.get_env(:sparrow, :google_auth_url) do 59 | nil -> [] 60 | url -> [{:url, url}] 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/sparrow/fcm/v1/webpush.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.Webpush do 2 | @moduledoc """ 3 | Struct reflecting FCM object(WebpushConfig). 4 | https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?authuser=1#WebpushConfig 5 | """ 6 | 7 | alias Sparrow.H2Worker.Request 8 | 9 | @type headers :: Request.headers() 10 | @type t :: %__MODULE__{ 11 | headers: headers, 12 | data: map, 13 | web_notification: Sparrow.FCM.V1.Webpush.Notification.t(), 14 | link: String.t() 15 | } 16 | 17 | defstruct [ 18 | :headers, 19 | :data, 20 | :web_notification, 21 | :link 22 | ] 23 | 24 | @doc """ 25 | Function to create new `Sparrow.FCM.V1.Webpush`. 26 | 27 | ## Arguments 28 | 29 | * `link` - The link to open when the user clicks on the notification. For all URL values, HTTPS is required. 30 | * `data` - (optional) Arbitrary key/value payload. If present, it will override google.firebase.fcm.v1.Message.data. 31 | """ 32 | @spec new(String.t(), map) :: t 33 | def new(link, data \\ %{}) do 34 | %__MODULE__{ 35 | headers: [], 36 | data: data, 37 | web_notification: Sparrow.FCM.V1.Webpush.Notification.new(), 38 | link: link 39 | } 40 | end 41 | 42 | @doc """ 43 | Function to transfer`Sparrow.FCM.V1.Webpush` to map. 44 | """ 45 | @spec to_map(t) :: map 46 | def to_map(webpush) do 47 | %{ 48 | :headers => Map.new(webpush.headers), 49 | :data => webpush.data, 50 | :notification => 51 | Sparrow.FCM.V1.Webpush.Notification.to_map(webpush.web_notification), 52 | :fcm_options => %{ 53 | :link => webpush.link 54 | } 55 | } 56 | end 57 | 58 | @doc """ 59 | Function to add `Sparrow.FCM.V1.Webpush` header. 60 | HTTP headers defined in Webpush protocol. 61 | Refer to Webpush protocol for supported headers, e.g. "TTL": "15". 62 | """ 63 | @spec add_header(t, String.t(), String.t()) :: t 64 | def add_header(webpush, key, value) do 65 | %{webpush | headers: [{key, value} | webpush.headers]} 66 | end 67 | 68 | @doc """ 69 | Function to set `Sparrow.FCM.V1.Webpush` permission 70 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 71 | """ 72 | @spec add_permission(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 73 | def add_permission(webpush, value) do 74 | add_to_web_notofication(webpush, :permission, value) 75 | end 76 | 77 | @doc """ 78 | Function to set `Sparrow.FCM.V1.Webpush` actions 79 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 80 | """ 81 | @spec add_actions(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 82 | def add_actions(webpush, value) do 83 | add_to_web_notofication(webpush, :actions, value) 84 | end 85 | 86 | @doc """ 87 | Function to set `Sparrow.FCM.V1.Webpush` badge 88 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 89 | """ 90 | @spec add_badge(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 91 | def add_badge(webpush, value) do 92 | add_to_web_notofication(webpush, :badge, value) 93 | end 94 | 95 | @doc """ 96 | Function to set `Sparrow.FCM.V1.Webpush` body 97 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 98 | """ 99 | @spec add_body(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 100 | def add_body(webpush, value) do 101 | add_to_web_notofication(webpush, :body, value) 102 | end 103 | 104 | @doc """ 105 | Function to set `Sparrow.FCM.V1.Webpush` web_notification_data 106 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 107 | """ 108 | @spec add_web_notification_data( 109 | t, 110 | Sparrow.FCM.V1.Webpush.Notification.value() 111 | ) :: t 112 | def add_web_notification_data(webpush, value) do 113 | add_to_web_notofication(webpush, :data, value) 114 | end 115 | 116 | @doc """ 117 | Function to set `Sparrow.FCM.V1.Webpush` dir 118 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 119 | """ 120 | @spec add_dir(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 121 | def add_dir(webpush, value) do 122 | add_to_web_notofication(webpush, :dir, value) 123 | end 124 | 125 | @doc """ 126 | Function to set `Sparrow.FCM.V1.Webpush` lang 127 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 128 | """ 129 | @spec add_lang(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 130 | def add_lang(webpush, value) do 131 | add_to_web_notofication(webpush, :lang, value) 132 | end 133 | 134 | @doc """ 135 | Function to set `Sparrow.FCM.V1.Webpush` tag 136 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 137 | """ 138 | @spec add_tag(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 139 | def add_tag(webpush, value) do 140 | add_to_web_notofication(webpush, :tag, value) 141 | end 142 | 143 | @doc """ 144 | Function to set `Sparrow.FCM.V1.Webpush` icon 145 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 146 | """ 147 | @spec add_icon(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 148 | def add_icon(webpush, value) do 149 | add_to_web_notofication(webpush, :icon, value) 150 | end 151 | 152 | @doc """ 153 | Function to set `Sparrow.FCM.V1.Webpush` image 154 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 155 | """ 156 | @spec add_image(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 157 | def add_image(webpush, value) do 158 | add_to_web_notofication(webpush, :image, value) 159 | end 160 | 161 | @doc """ 162 | Function to set `Sparrow.FCM.V1.Webpush` renotify 163 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 164 | """ 165 | @spec add_renotify(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 166 | def add_renotify(webpush, value) do 167 | add_to_web_notofication(webpush, :renotify, value) 168 | end 169 | 170 | @doc """ 171 | Function to set `Sparrow.FCM.V1.Webpush` requireInteraction 172 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 173 | """ 174 | @spec add_require_interaction(t, boolean) :: t 175 | def add_require_interaction(webpush, value) do 176 | add_to_web_notofication(webpush, :requireInteraction, value) 177 | end 178 | 179 | @doc """ 180 | Function to set `Sparrow.FCM.V1.Webpush` silent 181 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 182 | """ 183 | @spec add_silent(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 184 | def add_silent(webpush, value) do 185 | add_to_web_notofication(webpush, :silent, value) 186 | end 187 | 188 | @doc """ 189 | Function to set `Sparrow.FCM.V1.Webpush` timestamp 190 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 191 | """ 192 | @spec add_timestamp(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 193 | def add_timestamp(webpush, value) do 194 | add_to_web_notofication(webpush, :timestamp, value) 195 | end 196 | 197 | @doc """ 198 | Function to set `Sparrow.FCM.V1.Webpush` title 199 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 200 | """ 201 | @spec add_title(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 202 | def add_title(webpush, value) do 203 | add_to_web_notofication(webpush, :title, value) 204 | end 205 | 206 | @doc """ 207 | Function to set `Sparrow.FCM.V1.Webpush` vibrate 208 | See https://developer.mozilla.org/en-US/docs/Web/API/Notification 209 | """ 210 | @spec add_vibrate(t, Sparrow.FCM.V1.Webpush.Notification.value()) :: t 211 | def add_vibrate(webpush, value) do 212 | add_to_web_notofication(webpush, :vibrate, value) 213 | end 214 | 215 | @spec add_to_web_notofication( 216 | t, 217 | Sparrow.FCM.V1.Webpush.Notification.key(), 218 | Sparrow.FCM.V1.Webpush.Notification.value() 219 | ) :: t 220 | defp add_to_web_notofication(webpush, key, value) do 221 | updated_web_notification = 222 | webpush.web_notification 223 | |> Sparrow.FCM.V1.Webpush.Notification.add(key, value) 224 | 225 | %{webpush | web_notification: updated_web_notification} 226 | end 227 | 228 | @spec normalize(t | nil) :: {:ok, t | nil} | {:error, :invalid_notification} 229 | 230 | def normalize(nil), do: {:ok, nil} 231 | 232 | def normalize(notification) do 233 | data = Enum.map(notification.data, &normalize_value/1) 234 | 235 | case Enum.all?(data) do 236 | false -> 237 | {:error, :invalid_notification} 238 | 239 | true -> 240 | {:ok, %{notification | data: Map.new(data)}} 241 | end 242 | end 243 | 244 | defp normalize_value({k, v}) do 245 | {k, to_string(v)} 246 | rescue 247 | Protocol.UndefinedError -> false 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /lib/sparrow/fcm/v1/webpush/notification.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.Webpush.Notification do 2 | @moduledoc false 3 | @type permission :: :denied | :granted | :default 4 | @type field :: 5 | {:permission, permission} 6 | | {:actions, String.t()} 7 | | {:badge, String.t()} 8 | | {:body, String.t()} 9 | | {:data, map} 10 | | {:dir, String.t()} 11 | | {:lang, String.t()} 12 | | {:tag, String.t()} 13 | | {:icon, String.t()} 14 | | {:image, String.t()} 15 | | {:renotify, String.t()} 16 | | {:requireInteraction, boolean} 17 | | {:silent, String.t()} 18 | | {:timestamp, String.t()} 19 | | {:title, String.t()} 20 | | {:vibrate, String.t()} 21 | @type key :: 22 | :permission 23 | | :actions 24 | | :badge 25 | | :body 26 | | :data 27 | | :dir 28 | | :lang 29 | | :tag 30 | | :icon 31 | | :image 32 | | :renotify 33 | | :requireInteraction 34 | | :silent 35 | | :timestamp 36 | | :title 37 | | :vibrate 38 | @type value :: permission | String.t() | map | boolean 39 | @type t :: %__MODULE__{ 40 | fields: [field] 41 | } 42 | defstruct [ 43 | :fields 44 | ] 45 | 46 | @spec new([field]) :: __MODULE__.t() 47 | def new(fields \\ []) do 48 | %__MODULE__{ 49 | fields: fields 50 | } 51 | end 52 | 53 | @spec add(t, key, value) :: t 54 | def add(web_notification, key, value) do 55 | %{web_notification | fields: [{key, value} | web_notification.fields]} 56 | end 57 | 58 | def to_map(web_notification) do 59 | Map.new(web_notification.fields) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/sparrow/fcm_v1.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1 do 2 | @moduledoc """ 3 | Provides functions to build and send push notifications to FCM v1. 4 | """ 5 | use Sparrow.Telemetry.Timer 6 | require Logger 7 | 8 | alias Sparrow.H2Worker.Request 9 | 10 | @type reason :: atom 11 | @type headers :: Request.headers() 12 | @type body :: String.t() 13 | @type push_opts :: [{:is_sync, boolean()} | {:timeout, non_neg_integer}] 14 | @type android :: Sparrow.FCM.V1.Notification.android() 15 | @type webpush :: Sparrow.FCM.V1.Notification.webpush() 16 | @type apns :: Sparrow.FCM.V1.Notification.apns() 17 | @type authentication :: Sparrow.H2Worker.Config.authentication() 18 | @type tls_options :: Sparrow.H2Worker.Config.tls_options() 19 | @type time_in_miliseconds :: Sparrow.H2Worker.Config.time_in_miliseconds() 20 | @type http_status :: non_neg_integer 21 | @type sync_push_result :: 22 | {:error, :connection_lost} 23 | | {:ok, {headers, body}} 24 | | {:error, :request_timeout} 25 | | {:error, :not_ready} 26 | | {:error, :invalid_notification} 27 | | {:error, reason} 28 | 29 | @doc """ 30 | Sends the push notification to FCM v1. 31 | 32 | ## Options 33 | 34 | * `:is_sync` - Determines whether the worker should wait for response after sending the request. When set to `true` (default), the result of calling this functions is one of: 35 | * `:ok` when the response is received. 36 | * `{:error, :request_timeout}` when the response doesn't arrive until timeout occurs (see the `:timeout` option). 37 | * `{:error, :connection_lost}` when the connection to FCM is lost before the response arrives. 38 | * `{:error, :not_ready}` when stream response is not yet ready, but it h2worker tries to get it. 39 | * `{:error, :invalid_notification}` when notification does not contain neither title nor body. 40 | * `{:error, :reason}` when error with other reason occures. 41 | * `:timeout` - Request timeout in milliseconds. Defaults value is 5000. 42 | """ 43 | @timed event_tags: [:push, :fcm] 44 | @spec push( 45 | atom, 46 | Sparrow.FCM.V1.Notification.t(), 47 | push_opts 48 | ) :: sync_push_result | :ok 49 | def push(h2_worker_pool, notification, opts) do 50 | case Sparrow.FCM.V1.Notification.normalize(notification) do 51 | {:error, reason} -> 52 | {:error, reason} 53 | 54 | {:ok, notification} -> 55 | do_push(h2_worker_pool, notification, opts) 56 | end 57 | end 58 | 59 | def push(h2_worker_pool, notification), 60 | do: push(h2_worker_pool, notification, []) 61 | 62 | @spec do_push( 63 | atom, 64 | Sparrow.FCM.V1.Notification.t(), 65 | push_opts 66 | ) :: sync_push_result | :ok 67 | def do_push(h2_worker_pool, notification, opts) do 68 | # Prep FCM's ProjectId 69 | project_id = Sparrow.FCM.V1.ProjectIdBearer.get_project_id(h2_worker_pool) 70 | 71 | notification = 72 | Sparrow.FCM.V1.Notification.add_project_id(notification, project_id) 73 | 74 | is_sync = Keyword.get(opts, :is_sync, true) 75 | timeout = Keyword.get(opts, :timeout, 5_000) 76 | strategy = Keyword.get(opts, :strategy, :random_worker) 77 | headers = notification.headers 78 | json_body = notification |> make_body() |> Jason.encode!() 79 | path = path(notification.project_id) 80 | request = Request.new(headers, json_body, path, timeout) 81 | 82 | _ = 83 | Logger.debug("Sending FCM notification", 84 | what: :push_fcm_notification, 85 | request: request 86 | ) 87 | 88 | h2_worker_pool 89 | |> Sparrow.H2Worker.Pool.send_request( 90 | request, 91 | is_sync, 92 | timeout, 93 | strategy 94 | ) 95 | |> process_response() 96 | end 97 | 98 | @spec process_response(:ok | {:ok, {headers, body}} | {:error, reason}) :: 99 | :ok 100 | | {:error, reason :: :request_timeout | :not_ready | reason} 101 | 102 | def process_response(:ok) do 103 | _ = 104 | Logger.debug("Processing async FCM notification response", 105 | what: :async_fcm_push_response 106 | ) 107 | 108 | :ok 109 | end 110 | 111 | def process_response({:ok, {headers, body}}) do 112 | _ = 113 | Logger.debug("Processing FCM notification response", 114 | what: :fcm_push_response, 115 | raw: inspect({:ok, {headers, body}}) 116 | ) 117 | 118 | if {":status", "200"} in headers do 119 | _ = 120 | Logger.debug("Processing FCM notification response", 121 | what: :fcm_push_response, 122 | result: :success, 123 | status: "200" 124 | ) 125 | 126 | :ok 127 | else 128 | status = get_status_from_headers(headers) 129 | 130 | _ = 131 | Logger.debug("Processing FCM notification response", 132 | what: :fcm_push_response, 133 | result: :error, 134 | status: inspect(status) 135 | ) 136 | 137 | reason = 138 | body 139 | |> get_reason_from_body() 140 | |> String.to_atom() 141 | 142 | _ = 143 | Logger.warning("Processing FCM notification response", 144 | what: :fcm_push_response, 145 | result: :error, 146 | response_body: inspect(body) 147 | ) 148 | 149 | {:error, reason} 150 | end 151 | end 152 | 153 | def process_response({:error, reason}), do: {:error, reason} 154 | 155 | @doc """ 156 | Function providing `Sparrow.H2Worker.Authentication.TokenBased` for FCM pools. 157 | Requres `Sparrow.FCM.TokenBearer` to be started. 158 | """ 159 | @spec get_token_based_authentication(String.t()) :: 160 | Sparrow.H2Worker.Authentication.TokenBased.t() 161 | def get_token_based_authentication(account) do 162 | getter = fn -> 163 | {"authorization", 164 | "Bearer #{Sparrow.FCM.V1.TokenBearer.get_token(account)}"} 165 | end 166 | 167 | Sparrow.H2Worker.Authentication.TokenBased.new(getter) 168 | end 169 | 170 | @doc """ 171 | Function providing `Sparrow.H2Worker.Config` for FCM pools. 172 | 173 | ## Example 174 | 175 | # Token based authentication: 176 | config = 177 | Sparrow.FCM.V1.get_token_based_authentication() 178 | |> Sparrow.FCM.V1.get_h2worker_config() 179 | 180 | """ 181 | @spec get_h2worker_config( 182 | authentication, 183 | String.t(), 184 | pos_integer, 185 | tls_options, 186 | time_in_miliseconds, 187 | pos_integer 188 | ) :: Sparrow.H2Worker.Config.t() 189 | def get_h2worker_config( 190 | authentication, 191 | uri \\ "fcm.googleapis.com", 192 | port \\ 443, 193 | tls_opts \\ [], 194 | ping_interval \\ 5000, 195 | reconnect_attempts \\ 3 196 | ) do 197 | Sparrow.H2Worker.Config.new(%{ 198 | domain: uri, 199 | port: port, 200 | authentication: authentication, 201 | tls_options: tls_opts, 202 | ping_interval: ping_interval, 203 | reconnect_attempts: reconnect_attempts, 204 | pool_type: :fcm 205 | }) 206 | end 207 | 208 | @spec make_body(Sparrow.FCM.V1.Notification.t()) :: map 209 | defp make_body(notification) do 210 | message = 211 | %{ 212 | :data => notification.data, 213 | :notification => build_notification(notification), 214 | notification.target_type => notification.target 215 | } 216 | |> maybe_add_android(notification.android) 217 | |> maybe_add_webpush(notification.webpush) 218 | |> maybe_add_apns(notification.apns) 219 | 220 | %{message: message} 221 | end 222 | 223 | @spec build_notification(Sparrow.FCM.V1.Notification.t()) :: map 224 | defp build_notification(notification) do 225 | maybe_title = 226 | if notification.title != nil do 227 | %{:title => notification.title} 228 | else 229 | %{} 230 | end 231 | 232 | maybe_body = 233 | if notification.body != nil do 234 | %{:body => notification.body} 235 | else 236 | %{} 237 | end 238 | 239 | Map.merge(maybe_title, maybe_body) 240 | end 241 | 242 | @spec maybe_add_android(map, android) :: map 243 | defp maybe_add_android(body, nil) do 244 | body 245 | end 246 | 247 | defp maybe_add_android(body, android) do 248 | Map.put(body, :android, Sparrow.FCM.V1.Android.to_map(android)) 249 | end 250 | 251 | @spec maybe_add_webpush(map, webpush) :: map 252 | defp maybe_add_webpush(body, nil) do 253 | body 254 | end 255 | 256 | defp maybe_add_webpush(body, webpush) do 257 | Map.put(body, :webpush, Sparrow.FCM.V1.Webpush.to_map(webpush)) 258 | end 259 | 260 | @spec maybe_add_apns(map, apns) :: map 261 | defp maybe_add_apns(body, nil) do 262 | body 263 | end 264 | 265 | defp maybe_add_apns(body, apns) do 266 | Map.put(body, :apns, Sparrow.FCM.V1.APNS.to_map(apns)) 267 | end 268 | 269 | @spec path(String.t()) :: String.t() 270 | defp path(project_id) do 271 | "/v1/projects/#{project_id}/messages:send" 272 | end 273 | 274 | @spec get_status_from_headers(headers) :: http_status 275 | defp get_status_from_headers(headers) do 276 | {_, status} = List.keyfind(headers, ":status", 0) 277 | {result, _} = Integer.parse(status) 278 | result 279 | end 280 | 281 | @spec get_reason_from_body(String.t()) :: String.t() | nil 282 | defp get_reason_from_body(body) do 283 | error = 284 | body 285 | |> Jason.decode!() 286 | |> Map.get("error") 287 | 288 | fcm_error = 289 | Enum.find(error["details"] || [], fn detail -> 290 | Map.get(detail, "@type") == 291 | "type.googleapis.com/google.firebase.fcm.v1.FcmError" 292 | end) 293 | 294 | case fcm_error do 295 | %{"errorCode" => ec} -> 296 | ec 297 | 298 | _ -> 299 | # If there are no details, return the status 300 | error["status"] 301 | end 302 | end 303 | end 304 | -------------------------------------------------------------------------------- /lib/sparrow/h2_client_adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2ClientAdapter do 2 | @moduledoc false 3 | 4 | @default %{adapter: Sparrow.H2ClientAdapter.Chatterbox} 5 | 6 | @type connection_ref :: pid 7 | @type stream_id :: non_neg_integer 8 | @type headers :: [{String.t(), String.t()}] 9 | @type body :: String.t() 10 | @type reason :: term 11 | 12 | @doc """ 13 | Starts a new connection. 14 | """ 15 | @callback open(String.t(), non_neg_integer, [any]) :: 16 | {:ok, connection_ref} | {:error, :ignore} | {:error, reason} 17 | 18 | @doc """ 19 | Closes the connection. 20 | """ 21 | @callback close(connection_ref) :: :ok 22 | 23 | @doc """ 24 | Opens a new stream and sends request through it. 25 | DONT PASS PSEUDO HEADERS IN `headers`!!! 26 | """ 27 | @callback post(connection_ref, String.t(), String.t(), headers, body) :: 28 | {:error, byte()} | {:ok, stream_id} 29 | 30 | @doc """ 31 | Allows to read answer to notification. 32 | """ 33 | @callback get_response(connection_ref, stream_id) :: 34 | {:ok, {headers, body}} | {:error, :not_ready} 35 | 36 | @doc """ 37 | Sends ping to given connection. 38 | """ 39 | @callback ping(connection_ref) :: :ok 40 | 41 | def open(domain, port, opts \\ []) do 42 | adapter = Application.get_env(:sparrow, __MODULE__, @default)[:adapter] 43 | adapter.open(domain, port, opts) 44 | end 45 | 46 | def close(conn) do 47 | adapter = Application.get_env(:sparrow, __MODULE__, @default)[:adapter] 48 | adapter.close(conn) 49 | end 50 | 51 | def post(conn, domain, path, headers, body) do 52 | adapter = Application.get_env(:sparrow, __MODULE__, @default)[:adapter] 53 | adapter.post(conn, domain, path, headers, body) 54 | end 55 | 56 | def get_response(conn, stream_id) do 57 | adapter = Application.get_env(:sparrow, __MODULE__, @default)[:adapter] 58 | adapter.get_response(conn, stream_id) 59 | end 60 | 61 | def ping(conn) do 62 | adapter = Application.get_env(:sparrow, __MODULE__, @default)[:adapter] 63 | adapter.ping(conn) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/sparrow/h2_client_adapter/chatterbox.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2ClientAdapter.Chatterbox do 2 | @behaviour Sparrow.H2ClientAdapter 3 | 4 | @moduledoc false 5 | require Logger 6 | 7 | @type connection_ref :: pid 8 | @type stream_id :: non_neg_integer 9 | @type headers :: [{String.t(), String.t()}] 10 | @type body :: String.t() 11 | @type reason :: term 12 | 13 | @doc """ 14 | Starts a new connection. 15 | """ 16 | @spec open(String.t(), non_neg_integer, [any]) :: 17 | {:ok, connection_ref} | {:error, :ignore} | {:error, any} 18 | @impl true 19 | def open(domain, port, opts \\ []) do 20 | _ = 21 | Logger.debug("Opening HTTP/2 connection", 22 | what: :http_open, 23 | domain: domain, 24 | port: port, 25 | opts: inspect(opts) 26 | ) 27 | 28 | case :h2_client.start(:https, to_charlist(domain), port, opts) do 29 | :ignore -> 30 | _ = 31 | Logger.debug("Error while opening HTTP/2 connection", 32 | what: :http_open, 33 | status: :error, 34 | domain: domain, 35 | port: port, 36 | reason: :ignore 37 | ) 38 | 39 | {:error, :ignore} 40 | 41 | {:ok, connection_ref} -> 42 | _ = 43 | Logger.debug("HTTP/2 connection opened", 44 | what: :http_open, 45 | status: :error, 46 | domain: domain, 47 | port: port, 48 | connection: connection_ref 49 | ) 50 | 51 | {:ok, connection_ref} 52 | 53 | {:error, reason} -> 54 | _ = 55 | Logger.debug("Error while opening HTTP/2 connection", 56 | what: :http_open, 57 | status: :error, 58 | domain: domain, 59 | port: port, 60 | reason: inspect(reason) 61 | ) 62 | 63 | {:error, reason} 64 | end 65 | end 66 | 67 | @doc """ 68 | Closes the connection. 69 | """ 70 | @spec close(connection_ref) :: :ok 71 | @impl true 72 | def close(conn) do 73 | :h2_client.stop(conn) 74 | end 75 | 76 | @doc """ 77 | Opens a new stream and sends request through it. 78 | DONT PASS PSEUDO HEADERS IN `headers`!!! 79 | """ 80 | @spec post(connection_ref, String.t(), String.t(), headers, body) :: 81 | {:error, byte()} | {:ok, stream_id} 82 | @impl true 83 | def post(conn, domain, path, headers, body) do 84 | headers = make_headers(:post, domain, path, headers, body) 85 | 86 | try do 87 | :h2_client.send_request(conn, headers, body) 88 | catch 89 | # We may loose connection mid-request 90 | :exit, reason -> 91 | _ = 92 | Logger.debug("Error while sending HTTP request", 93 | what: :http_send, 94 | method: :post, 95 | headers: headers, 96 | body: inspect(body), 97 | status: :error, 98 | reason: inspect(reason) 99 | ) 100 | 101 | {:error, :connection_lost} 102 | end 103 | end 104 | 105 | @doc """ 106 | Allows to read answer to notification. 107 | """ 108 | @spec get_response(connection_ref, stream_id) :: 109 | {:ok, {headers, body}} | {:error, :not_ready} 110 | @impl true 111 | def get_response(conn, stream_id) do 112 | case :h2_connection.get_response(conn, stream_id) do 113 | {:ok, {headers, body}} -> 114 | {:ok, {headers, IO.iodata_to_binary(body)}} 115 | 116 | :not_ready -> 117 | {:error, :not_ready} 118 | end 119 | end 120 | 121 | @doc """ 122 | Sends ping to given connection. 123 | """ 124 | @spec ping(connection_ref) :: :ok 125 | @impl true 126 | def ping(conn) do 127 | :h2_client.send_ping(conn) 128 | catch 129 | # We may loose connection mid-request 130 | :exit, reason -> 131 | _ = 132 | Logger.debug("Error while sending HTTP ping", 133 | what: :http_send, 134 | method: :ping, 135 | status: :error, 136 | reason: inspect(reason) 137 | ) 138 | 139 | {:error, :connection_lost} 140 | end 141 | 142 | @spec make_headers(:post, String.t(), String.t(), headers, body) :: headers 143 | defp make_headers(method, domain, path, headers, body) do 144 | [ 145 | {":method", String.upcase(Atom.to_string(method))}, 146 | {":path", path}, 147 | {":scheme", "https"}, 148 | {":authority", domain}, 149 | {"content-length", "#{byte_size(body)}"} 150 | | headers 151 | ] 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /lib/sparrow/h2_worker/authentication/certificate_based.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2Worker.Authentication.CertificateBased do 2 | @moduledoc """ 3 | Structure for cerificate based authentication. 4 | Use to create `Sparrow.H2Worker.Config` for `Sparrow.H2Worker`. 5 | """ 6 | @type t :: %__MODULE__{ 7 | certfile: Path.t(), 8 | keyfile: Path.t() 9 | } 10 | defstruct [ 11 | :certfile, 12 | :keyfile 13 | ] 14 | 15 | @spec new(Path.t(), Path.t()) :: t 16 | def new(certfile, keyfile) do 17 | %__MODULE__{ 18 | certfile: certfile, 19 | keyfile: keyfile 20 | } 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/sparrow/h2_worker/authentication/token_based.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2Worker.Authentication.TokenBased do 2 | @moduledoc """ 3 | Structure for token based authentication. 4 | Use to create Config for H2Worker. 5 | token_getter is a function returning tuple representing authentication header. 6 | 7 | ## Example 1 8 | 9 | import Sparrow.APNS.TokenBearer 10 | # For APNS token can be obtanin from `Sparrow.APNS.TokenBearer.get_token(token_id)` 11 | token_getter = fn -> {"authorization", "bearer \#{get_token(token_id)}"} end 12 | """ 13 | @type token_getter :: (-> {String.t(), String.t()}) 14 | @type t :: %__MODULE__{ 15 | token_getter: token_getter 16 | } 17 | 18 | defstruct [ 19 | :token_getter 20 | ] 21 | 22 | @spec new(token_getter) :: t 23 | def new(token_getter) do 24 | %__MODULE__{ 25 | token_getter: token_getter 26 | } 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/sparrow/h2_worker/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2Worker.Config do 2 | @moduledoc """ 3 | Structure for `Sparrow.H2Worker` config. 4 | """ 5 | @type time_in_miliseconds :: non_neg_integer 6 | @type port_num :: non_neg_integer 7 | @type tls_options :: [any] 8 | @type authentication :: 9 | Sparrow.H2Worker.Authentication.TokenBased.t() 10 | | Sparrow.H2Worker.Authentication.CertificateBased.t() 11 | 12 | @type t :: %__MODULE__{ 13 | domain: String.t(), 14 | port: non_neg_integer, 15 | authentication: authentication, 16 | tls_options: tls_options, 17 | ping_interval: time_in_miliseconds | nil, 18 | reconnect_attempts: pos_integer, 19 | backoff_initial_delay: pos_integer, 20 | backoff_max_delay: pos_integer, 21 | backoff_base: pos_integer, 22 | pool_type: Sparrow.PoolsWarden.pool_type(), 23 | pool_name: atom, 24 | pool_tags: [atom] 25 | } 26 | 27 | defstruct [ 28 | :domain, 29 | :port, 30 | :authentication, 31 | :tls_options, 32 | :ping_interval, 33 | :reconnect_attempts, 34 | :backoff_initial_delay, 35 | :backoff_max_delay, 36 | :backoff_base, 37 | :pool_type, 38 | :pool_name, 39 | :pool_tags 40 | ] 41 | 42 | @doc """ 43 | Function new creates h2 worker configuration. 44 | 45 | ## Arguments 46 | 47 | * `domain` - service address eg. "www.erlang-solutions.com" 48 | * `port` - port service works on, 49 | * `authentication` - a struct to provide token based or certificate based authentication 50 | * `tls_options` - See http://erlang.org/doc/man/ssl.html ssl_option() 51 | * `ping_interval` - ping message is send to server periodically after ping_interval miliseconds (default 5_000) 52 | * `reconnect_attempts` - number of attempts to start connection before it fails (default 3) 53 | 54 | WARNING! If you use certificate based authentication do not add certfile and/or keyfile to `tls_options`, put them to `authentication` 55 | """ 56 | @spec new(map) :: t 57 | def new(specific) do 58 | %{ 59 | domain: domain, 60 | port: port, 61 | authentication: authentication, 62 | tls_options: tls_options, 63 | ping_interval: ping_interval, 64 | reconnect_attempts: reconnect_attempts, 65 | backoff_initial_delay: backoff_initial_delay, 66 | backoff_max_delay: backoff_max_delay, 67 | backoff_base: backoff_base, 68 | pool_type: pool_type, 69 | pool_name: pool_name, 70 | pool_tags: pool_tags 71 | } = Map.merge(default(), specific) 72 | 73 | %__MODULE__{ 74 | domain: domain, 75 | port: port, 76 | authentication: authentication, 77 | tls_options: tls_options, 78 | ping_interval: ping_interval, 79 | reconnect_attempts: reconnect_attempts, 80 | backoff_initial_delay: backoff_initial_delay, 81 | backoff_max_delay: backoff_max_delay, 82 | backoff_base: backoff_base, 83 | pool_type: pool_type, 84 | pool_name: pool_name, 85 | pool_tags: pool_tags 86 | } 87 | end 88 | 89 | defp default do 90 | %{ 91 | tls_options: [], 92 | ping_interval: 5_000, 93 | reconnect_attempts: 3, 94 | backoff_base: 2, 95 | backoff_initial_delay: 100, 96 | backoff_max_delay: 5000, 97 | pool_type: nil, 98 | pool_name: nil, 99 | pool_tags: [] 100 | } 101 | end 102 | 103 | @spec get_authentication_type(__MODULE__.t()) :: 104 | :token_based | :certificate_based 105 | def get_authentication_type(config) do 106 | case config.authentication do 107 | %Sparrow.H2Worker.Authentication.TokenBased{} -> :token_based 108 | %Sparrow.H2Worker.Authentication.CertificateBased{} -> :certificate_based 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/sparrow/h2_worker/pool.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2Worker.Pool do 2 | @moduledoc """ 3 | Module providing functions to work on (`Sparrow.H2Worker`) worker pools and not single workers. 4 | """ 5 | @type request :: Sparrow.H2Worker.Request.t() 6 | @type strategy :: 7 | :best_worker 8 | | :random_worker 9 | | :next_worker 10 | | :available_worker 11 | | :next_available_worker 12 | @type body :: String.t() 13 | @type headers :: [{String.t(), String.t()}] 14 | @type reason :: atom 15 | @type worker_config :: Sparrow.H2Worker.Config.t() 16 | @type pool_type :: Sparrow.PoolsWarden.pool_type() 17 | @doc """ 18 | Sends the request and, if `is_sync` is `true`, awaits the response. 19 | 20 | ## Arguments 21 | 22 | * `worker_pool` - H2Workers pool name you want to send message with 23 | * `request` - HTTP2 request, see Sparrow.H2Worker.Request 24 | * `is_sync` - if `is_sync` is `true`, awaits the response, otherwize returns `:ok` 25 | * `genserver_timeout` - timeout of genserver call, works only if `is_sync` is `true` 26 | * `worker_choice_strategy` - worker selection strategy. See https://github.com/inaka/worker_pool section: "Choosing a Strategy" 27 | """ 28 | require Logger 29 | 30 | @spec send_request(atom, request, boolean(), non_neg_integer, strategy) :: 31 | {:error, :connection_lost} 32 | | {:ok, {headers, body}} 33 | | {:error, :request_timeout} 34 | | {:error, :not_ready} 35 | | {:error, reason} 36 | | :ok 37 | def send_request( 38 | worker_pool, 39 | request, 40 | is_sync \\ true, 41 | genserver_timeout \\ 60_000, 42 | worker_choice_strategy \\ :random_worker 43 | ) 44 | 45 | def send_request(worker_pool, request, false, _, strategy) do 46 | _ = 47 | Logger.debug("Dispatching request to worker pool", 48 | worker_pool: inspect(worker_pool), 49 | request: request, 50 | type: :cast 51 | ) 52 | 53 | :wpool.cast(worker_pool, {:send_request, request}, strategy) 54 | end 55 | 56 | def send_request(worker_pool, request, true, genserver_timeout, strategy) do 57 | _ = 58 | Logger.debug("Dispatching request to worker pool", 59 | worker_pool: inspect(worker_pool), 60 | request: request, 61 | type: :call 62 | ) 63 | 64 | :wpool.call( 65 | worker_pool, 66 | {:send_request, request}, 67 | strategy, 68 | genserver_timeout 69 | ) 70 | end 71 | 72 | @doc """ 73 | Function to start pool. 74 | """ 75 | @spec start_unregistered(Sparrow.H2Worker.Pool.Config.t(), pool_type, [atom]) :: 76 | {:error, any} | {:ok, pid} 77 | def start_unregistered(config, pool_type, tags \\ []) do 78 | # We add pool information to worker config only for the telemetry events 79 | worker_config_with_pool = %Sparrow.H2Worker.Config{ 80 | config.workers_config 81 | | pool_type: pool_type, 82 | pool_name: config.pool_name, 83 | pool_tags: tags 84 | } 85 | 86 | :wpool.start_pool( 87 | config.pool_name, 88 | [ 89 | {:workers, config.worker_num}, 90 | {:worker, {Sparrow.H2Worker, worker_config_with_pool}} 91 | | config.raw_opts 92 | ] 93 | ) 94 | end 95 | 96 | @doc """ 97 | Function to start pool and "register" it in pool warden. 98 | """ 99 | @spec start_link(Sparrow.H2Worker.Pool.Config.t(), pool_type, [atom]) :: 100 | {:ok, pid} 101 | def start_link(config, pool_type, tags \\ []) do 102 | pool_name = config.pool_name 103 | {:ok, pid} = start_unregistered(config, pool_type, tags) 104 | Sparrow.PoolsWarden.add_new_pool(pid, pool_type, pool_name, tags) 105 | {:ok, pid} 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/sparrow/h2_worker/pool/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2Worker.Pool.Config do 2 | @moduledoc """ 3 | Structure for starting `Sparrow.H2Worker.Pool`. 4 | """ 5 | @type t :: %__MODULE__{ 6 | pool_name: atom, 7 | workers_config: Sparrow.H2Worker.Config.t(), 8 | worker_num: pos_integer, 9 | raw_opts: Keyword.t() 10 | } 11 | 12 | defstruct [ 13 | :pool_name, 14 | :workers_config, 15 | :worker_num, 16 | :raw_opts 17 | ] 18 | 19 | @doc """ 20 | Function `Sparrow.H2Worker.Pool.Config.new/2,3,4` creates workers pool configuration. 21 | 22 | ## Arguments 23 | 24 | * `workers_config` - config for each worker in pool. See `Sparrow.H2Worker.Config`. Config of a single worker for APNS see `Sparrow.APNS.get_h2worker_config_dev/1,2,3,4,5,6` and for FCM see `Sparrow.FCM.V1.get_h2worker_config/1,2,3,4,5,6` 25 | * `pool_name` - name of workers pool, when set to `nil` name is generated automatically 26 | * `worker_num` - number of workers in a pool 27 | * `raw_opts` - extra config options to pass to wpool. For details see https://github.com/inaka/worker_pool 28 | """ 29 | @spec new(Sparrow.H2Worker.Config.t(), atom | nil, pos_integer, Keyword.t()) :: 30 | t 31 | def new( 32 | workers_config, 33 | pool_name \\ nil, 34 | worker_num \\ 3, 35 | raw_opts \\ [] 36 | ) 37 | 38 | def new( 39 | workers_config, 40 | nil, 41 | worker_num, 42 | raw_opts 43 | ) do 44 | %__MODULE__{ 45 | pool_name: random_atom(20), 46 | workers_config: workers_config, 47 | worker_num: worker_num, 48 | raw_opts: raw_opts 49 | } 50 | end 51 | 52 | def new( 53 | workers_config, 54 | pool_name, 55 | worker_num, 56 | raw_opts 57 | ) do 58 | %__MODULE__{ 59 | pool_name: pool_name, 60 | workers_config: workers_config, 61 | worker_num: worker_num, 62 | raw_opts: raw_opts 63 | } 64 | end 65 | 66 | @chars "ABCDEFGHIJKLMNOPQRSTUVWXYZ" |> String.split("") 67 | 68 | defp random_atom(len) do 69 | 1..len 70 | |> Enum.reduce([], fn _i, acc -> 71 | [Enum.random(@chars) | acc] 72 | end) 73 | |> Enum.join("") 74 | |> String.to_atom() 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/sparrow/h2_worker/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2Worker.Request do 2 | @moduledoc """ 3 | Struct to pass request to worker. 4 | """ 5 | @type headers :: [{String.t(), String.t()}] 6 | @type body :: String.t() 7 | @type time_in_miliseconds :: non_neg_integer 8 | 9 | @type t :: %__MODULE__{ 10 | headers: headers, 11 | body: body, 12 | path: String.t(), 13 | timeout: time_in_miliseconds 14 | } 15 | 16 | defstruct [ 17 | :headers, 18 | :body, 19 | :path, 20 | :timeout 21 | ] 22 | 23 | @doc """ 24 | Function new creates request that can be passed to `Sparrow.H2Worker`. 25 | 26 | ## Arguments 27 | 28 | * `headers` - http request headers 29 | * `body` - http request body 30 | * `path` - path to resource on server eg. for address "https://www.erlang-solutions.com/events.html" path is "/events.html" 31 | * `timeout` - request timeout (default 5_000) 32 | """ 33 | @spec new(headers, body, String.t()) :: t 34 | def new(headers, body, path, timeout \\ 5_000) do 35 | %__MODULE__{headers: headers, body: body, path: path, timeout: timeout} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/sparrow/h2_worker/request_set.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2Worker.RequestSet do 2 | @moduledoc """ 3 | Abstraction over requests collection. 4 | """ 5 | alias Sparrow.H2Worker.RequestState 6 | 7 | @type stream_id :: non_neg_integer 8 | @type from :: {pid, tag :: term} 9 | @type requests :: %{required(stream_id) => %Sparrow.H2Worker.Request{}} 10 | 11 | @doc """ 12 | Creates new requests collection. 13 | """ 14 | @spec new() :: %{} 15 | def new do 16 | %{} 17 | end 18 | 19 | @doc """ 20 | Adds request to requests collection. 21 | """ 22 | @spec add(requests, stream_id, RequestState.t()) :: requests 23 | def add( 24 | other_requests, 25 | stream_id, 26 | new_request 27 | ) do 28 | Map.put(other_requests, stream_id, new_request) 29 | end 30 | 31 | @doc """ 32 | Removes request from requests collection. 33 | """ 34 | @spec remove(requests, stream_id) :: requests 35 | def remove( 36 | other_requests, 37 | stream_id 38 | ) do 39 | Map.delete(other_requests, stream_id) 40 | end 41 | 42 | @doc """ 43 | Gets request from requests collection by `stream_id` as search key. 44 | """ 45 | @spec get_request(requests, stream_id) :: 46 | {:ok, RequestState.t()} | {:error, :not_found} 47 | def get_request(requests, stream_id) do 48 | case Map.get(requests, stream_id, :not_found) do 49 | :not_found -> {:error, :not_found} 50 | request -> {:ok, request} 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/sparrow/h2_worker/request_state.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2Worker.RequestState do 2 | @moduledoc """ 3 | Struct for requests internal representation. 4 | """ 5 | alias Sparrow.H2Worker.Request 6 | 7 | @type headers :: [{String.t(), String.t()}] 8 | @type body :: String.t() 9 | @type from :: {pid, tag :: term} | :noreply 10 | @type timeout_reference :: reference 11 | 12 | @type t :: %__MODULE__{ 13 | headers: headers, 14 | body: body, 15 | path: String.t(), 16 | from: from, 17 | timeout_reference: timeout_reference 18 | } 19 | 20 | defstruct [ 21 | :headers, 22 | :body, 23 | :path, 24 | :timeout, 25 | :from, 26 | :timeout_reference 27 | ] 28 | 29 | @spec new(Request.t(), from, timeout_reference) :: t 30 | def new(request, from, timeout_reference) do 31 | %__MODULE__{ 32 | headers: request.headers, 33 | body: request.body, 34 | path: request.path, 35 | from: from, 36 | timeout_reference: timeout_reference 37 | } 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/sparrow/h2_worker/state.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2Worker.State do 2 | @moduledoc false 3 | @type connection_ref :: pid 4 | @type stream_id :: non_neg_integer 5 | @type requests :: %{required(stream_id) => %Sparrow.H2Worker.Request{}} 6 | @type config :: %Sparrow.H2Worker.Config{} 7 | 8 | @type t :: %__MODULE__{ 9 | connection_ref: connection_ref | nil, 10 | requests: requests, 11 | config: config, 12 | restart_connection_timer: term() | nil 13 | } 14 | 15 | defstruct [ 16 | :connection_ref, 17 | :requests, 18 | :config, 19 | :restart_connection_timer 20 | ] 21 | 22 | @doc """ 23 | Creates new empty `Sparrow.H2Worker.State`. 24 | """ 25 | @spec new(connection_ref | nil, requests, config) :: t 26 | def new(connection_ref, requests \\ %{}, config) 27 | 28 | def new(nil, requests, config) do 29 | %__MODULE__{ 30 | connection_ref: nil, 31 | requests: requests, 32 | config: config, 33 | restart_connection_timer: nil 34 | } 35 | end 36 | 37 | def new(connection_ref, requests, config) do 38 | %__MODULE__{ 39 | connection_ref: connection_ref, 40 | requests: requests, 41 | config: config, 42 | restart_connection_timer: nil 43 | } 44 | end 45 | 46 | @doc """ 47 | Resets requests collection in `Sparrow.H2Worker.State`. 48 | """ 49 | @spec reset_requests_collection(t) :: t 50 | def reset_requests_collection(state) do 51 | %__MODULE__{ 52 | connection_ref: state.connection_ref, 53 | requests: %{}, 54 | config: state.config, 55 | restart_connection_timer: state.restart_connection_timer 56 | } 57 | end 58 | 59 | @doc """ 60 | Resets connection reference in `Sparrow.H2Worker.State`. 61 | """ 62 | @spec reset_connection_ref(t) :: t 63 | def reset_connection_ref(state) do 64 | %__MODULE__{ 65 | connection_ref: nil, 66 | requests: state.requests, 67 | config: state.config, 68 | restart_connection_timer: state.restart_connection_timer 69 | } 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/sparrow/pools_warden.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.PoolsWarden do 2 | @moduledoc """ 3 | Module to handle workers pools. 4 | """ 5 | 6 | use GenServer 7 | use Sparrow.Telemetry.Timer 8 | require Logger 9 | 10 | @type pool_type :: :fcm | {:apns, :dev} | {:apns, :prod} 11 | 12 | @tab_name :sparrow_pools_warden_tab 13 | 14 | @doc """ 15 | Function to "register" new workers pool, allows for `Sparrow.API.push/3` to automatically choose pool. 16 | 17 | ## Arguments 18 | * `pid` - PID of pool process. Needed to unregister pool when its process is killed 19 | * `pool_type` - determine if pool is FCM or APNS dev or APNS 20 | * `pool_name` - Name of the pool, chosen internally - for choosing specific pool use `tags` 21 | * `tags` - tags that allow to call a particular pool of subset of pools 22 | """ 23 | 24 | @spec add_new_pool(pid(), pool_type, atom, [any]) :: true 25 | def add_new_pool(pid, pool_type, pool_name, tags \\ []) do 26 | GenServer.call( 27 | __MODULE__, 28 | {:add_pool, pid, pool_type, pool_name, tags} 29 | ) 30 | end 31 | 32 | @doc """ 33 | Function to get all pools of certain `pool_type`. 34 | 35 | ## Arguments 36 | * `pool_type` - can be one of: 37 | * `:fcm` - to get FCM pools 38 | * `{:apns, :dev}` - to get APNS development pools 39 | * `{:apns, :prod}` - to get APNS production pools 40 | * `tags` - allows to filter pools, if tags are included only pools with all of these tags are selected 41 | """ 42 | 43 | @spec choose_pool(pool_type, [any]) :: atom | nil 44 | def choose_pool(pool_type, tags \\ []) do 45 | pools = get_pools(pool_type) 46 | 47 | result = 48 | for {_pool_type, {pool_name, pool_tags}} <- pools, 49 | Enum.all?(tags, fn e -> e in pool_tags end) do 50 | pool_name 51 | end 52 | 53 | _ = 54 | Logger.debug("Selecting connection pool", 55 | worker: :pools_warden, 56 | what: :choose_pool, 57 | result: result, 58 | result_len: Enum.count(result) 59 | ) 60 | 61 | chosen_pool = List.first(result) 62 | 63 | :telemetry.execute( 64 | [:sparrow, :pools_warden, :choose_pool], 65 | %{}, 66 | %{ 67 | pool_name: chosen_pool, 68 | pool_type: pool_type, 69 | pool_tags: tags 70 | } 71 | ) 72 | 73 | chosen_pool 74 | end 75 | 76 | @spec start_link(any) :: GenServer.on_start() 77 | def start_link(_) do 78 | start_link() 79 | end 80 | 81 | @spec start_link :: GenServer.on_start() 82 | def start_link do 83 | GenServer.start_link( 84 | Sparrow.PoolsWarden, 85 | :ok, 86 | name: __MODULE__ 87 | ) 88 | end 89 | 90 | @impl GenServer 91 | def init(_) do 92 | @tab_name = :ets.new(@tab_name, [:bag, :protected, :named_table]) 93 | 94 | _ = 95 | Logger.info("Starting PoolsWarden", 96 | worker: :pools_warden, 97 | what: :init, 98 | result: :success 99 | ) 100 | 101 | :telemetry.execute( 102 | [:sparrow, :pools_warden, :init], 103 | %{}, 104 | %{} 105 | ) 106 | 107 | {:ok, %{}} 108 | end 109 | 110 | @impl GenServer 111 | def terminate(reason, _state) do 112 | ets_del = :ets.delete(@tab_name) 113 | 114 | _ = 115 | Logger.info("Shutting down PoolsWarden", 116 | worker: :pools_warden, 117 | what: :terminate, 118 | reason: inspect(reason), 119 | ets_delate_result: inspect(ets_del) 120 | ) 121 | 122 | :telemetry.execute( 123 | [:sparrow, :pools_warden, :terminate], 124 | %{}, 125 | %{reason: reason} 126 | ) 127 | end 128 | 129 | @impl GenServer 130 | def handle_info({:DOWN, _ref, :process, pid, reason}, state) do 131 | [pool_type, pool_name, tags] = Map.get(state, pid) 132 | :ets.delete_object(@tab_name, {pool_type, {pool_name, tags}}) 133 | 134 | _ = 135 | Logger.info("Pool down", 136 | worker: :pools_warden, 137 | what: :pool_down, 138 | pid: inspect(pid), 139 | reason: inspect(reason) 140 | ) 141 | 142 | new_state = Map.delete(state, pid) 143 | 144 | :telemetry.execute( 145 | [:sparrow, :pools_warden, :pool_down], 146 | %{}, 147 | %{ 148 | pool_name: pool_name, 149 | pool_type: pool_type, 150 | pool_tags: tags, 151 | reason: reason 152 | } 153 | ) 154 | 155 | {:noreply, new_state} 156 | end 157 | 158 | def handle_info(unknown, state) do 159 | _ = 160 | Logger.warning("Unknown message", 161 | worker: :pools_warden, 162 | what: :unknown_message, 163 | message: inspect(unknown) 164 | ) 165 | 166 | {:noreply, state} 167 | end 168 | 169 | @impl GenServer 170 | def handle_call({:add_pool, pid, pool_type, pool_name, tags}, _, state) do 171 | Process.monitor(pid) 172 | :ets.insert(@tab_name, {pool_type, {pool_name, tags}}) 173 | 174 | _ = 175 | Logger.info("Pool added", 176 | worker: :pools_warden, 177 | what: :adding_pool, 178 | pool_type: pool_type, 179 | pool_name: pool_name, 180 | pool_tags: tags 181 | ) 182 | 183 | new_state = Map.merge(state, %{pid => [pool_type, pool_name, tags]}) 184 | 185 | :telemetry.execute( 186 | [:sparrow, :pools_warden, :add_pool], 187 | %{}, 188 | %{ 189 | pool_name: pool_name, 190 | pool_type: pool_type, 191 | pool_tags: tags 192 | } 193 | ) 194 | 195 | {:reply, pool_name, new_state} 196 | end 197 | 198 | @spec get_pools(pool_type) :: [{atom, [any]}] 199 | defp get_pools(pool_type) do 200 | :ets.lookup(@tab_name, pool_type) 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /lib/sparrow/telemetry/timer.ex: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.Telemetry.Timer do 2 | @moduledoc """ 3 | Module responsible for handling emitting telemetry events which measure time of execution 4 | """ 5 | require Logger 6 | 7 | defmacro __using__(_mod) do 8 | quote do 9 | import Sparrow.Telemetry.Timer 10 | Module.register_attribute(__MODULE__, :timed_functions, accumulate: true) 11 | @on_definition Sparrow.Telemetry.Timer 12 | @before_compile Sparrow.Telemetry.Timer 13 | end 14 | end 15 | 16 | def __on_definition__(env, _kind, name, args, guards, body) do 17 | module = env.module 18 | time_info = Module.get_attribute(module, :timed) 19 | 20 | if time_info do 21 | event_tags = time_info[:event_tags] 22 | 23 | Module.put_attribute(module, :timed_functions, %{ 24 | event_tags: event_tags, 25 | name: name, 26 | args: args, 27 | guards: guards, 28 | body: body 29 | }) 30 | 31 | Module.delete_attribute(module, :timed) 32 | end 33 | end 34 | 35 | defmacro __before_compile__(env) do 36 | module = env.module 37 | timed_funs = Module.get_attribute(module, :timed_functions) 38 | 39 | new_funs = 40 | timed_funs 41 | |> Enum.map(fn fun_info -> 42 | :ok = 43 | Module.make_overridable(module, [ 44 | {fun_info.name, length(fun_info.args)} 45 | ]) 46 | 47 | new_body = update_body(fun_info) 48 | 49 | if length(fun_info.guards) > 0 do 50 | quote generated: true do 51 | def unquote(fun_info.name)(unquote_splicing(fun_info.args)) 52 | when unquote_splicing(fun_info.guards) do 53 | unquote(new_body) 54 | end 55 | end 56 | else 57 | quote generated: true do 58 | def unquote(fun_info.name)(unquote_splicing(fun_info.args)) do 59 | unquote(new_body) 60 | end 61 | end 62 | end 63 | end) 64 | 65 | quote do 66 | (unquote_splicing(new_funs)) 67 | end 68 | end 69 | 70 | defp update_body(fun_info) do 71 | quote do 72 | t_start = Time.utc_now() 73 | res = unquote(Keyword.get(fun_info.body, :do)) 74 | t_end = Time.utc_now() 75 | t = abs(Time.diff(t_start, t_end, :microsecond)) 76 | 77 | :telemetry.execute( 78 | [:sparrow | unquote(fun_info.event_tags)], 79 | %{ 80 | time: t 81 | }, 82 | %{ 83 | args: unquote(fun_info.args) 84 | } 85 | ) 86 | 87 | res 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :sparrow, 7 | version: "1.0.2", 8 | elixir: "~> 1.17", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | elixirc_options: elixirc_options(), 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | dialyzer: dialyzer(), 14 | test_coverage: test_coverage() 15 | ] 16 | end 17 | 18 | def application do 19 | [ 20 | extra_applications: [:logger], 21 | mod: {Sparrow, []} 22 | ] 23 | end 24 | 25 | defp deps do 26 | [ 27 | {:dialyxir, "~> 1.4", runtime: false, only: [:dev, :test]}, 28 | {:credo, "~> 1.7", runtime: false, only: [:dev, :test]}, 29 | {:chatterbox, github: "joedevivo/chatterbox", ref: "c0506c7"}, 30 | {:certifi, "~> 2.12"}, 31 | {:excoveralls, "~> 0.18", runtime: false, only: :test}, 32 | {:quixir, "~> 0.9", only: :test}, 33 | {:uuid, "~> 1.1"}, 34 | {:jason, "~> 1.4"}, 35 | {:joken, "~> 2.6"}, 36 | {:poison, "~> 5.0"}, 37 | {:mox, "~> 1.1", only: :test}, 38 | {:mock, "~> 0.3", only: :test}, 39 | {:meck, github: "eproxus/meck", only: :test, override: true}, 40 | {:cowboy, "~> 2.11", only: :test}, 41 | {:lager, "~> 3.9", override: true}, 42 | {:logger_lager_backend, "~> 0.2"}, 43 | {:plug, "~> 1.15", only: :test}, 44 | {:goth, "~> 1.4"}, 45 | {:httpoison, "~> 2.2"}, 46 | {:worker_pool, "== 6.2.0"}, 47 | {:assert_eventually, "~> 1.0", only: [:test]}, 48 | {:telemetry, "~> 1.2"} 49 | ] 50 | end 51 | 52 | defp dialyzer do 53 | [ 54 | plt_core_path: ".dialyzer", 55 | flags: [ 56 | :unmatched_returns, 57 | :error_handling, 58 | :underspecs 59 | ], 60 | plt_add_apps: [:mix, :goth] 61 | ] 62 | end 63 | 64 | defp test_coverage do 65 | [tool: ExCoveralls] 66 | end 67 | 68 | defp elixirc_paths(:test), 69 | do: ["lib", "test/helpers"] 70 | 71 | defp elixirc_paths(_), do: ["lib"] 72 | 73 | defp elixirc_options() do 74 | [warnings_as_errors: true] 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /sparrow_token.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id": "sparrow-test-id", 4 | "private_key_id": "dummyid", 5 | "private_key": "-----BEGIN PRIVATE KEY-----\ndummy key\n-----END PRIVATE KEY-----\n", 6 | "client_email": "e@mail.com", 7 | "client_id": "12345543211", 8 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 9 | "token_uri": "https://oauth2.googleapis.com/token", 10 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 11 | "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/wolololo" 12 | } 13 | -------------------------------------------------------------------------------- /sparrow_token2.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id": "sparrow-test-id2", 4 | "private_key_id": "dummyid2", 5 | "private_key": "-----BEGIN PRIVATE KEY-----\ndummy key2\n-----END PRIVATE KEY-----\n", 6 | "client_email": "e2@mail.com", 7 | "client_id": "12345543211", 8 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 9 | "token_uri": "https://oauth2.googleapis.com/token", 10 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 11 | "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/wolololo" 12 | } 13 | -------------------------------------------------------------------------------- /test/api_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.APITest do 2 | use ExUnit.Case 3 | 4 | import Mock 5 | 6 | import Mox 7 | setup :set_mox_global 8 | setup :verify_on_exit! 9 | 10 | import Helpers.SetupHelper, only: [passthrough_h2: 1] 11 | setup :passthrough_h2 12 | 13 | test "FCM notification is send correctly" do 14 | with_mock Sparrow.FCM.V1, 15 | push: fn _, _, _ -> 16 | :ok 17 | end, 18 | process_response: fn _ -> :ok end do 19 | Sparrow.PoolsWarden.start_link() 20 | 21 | auth = 22 | Sparrow.H2Worker.Authentication.TokenBased.new(fn -> 23 | {"authorization", "my_dummy_fcm_token"} 24 | end) 25 | 26 | pool_1_config = 27 | Sparrow.H2Worker.Config.new(%{ 28 | domain: "fcm.googleapis.com", 29 | port: 443, 30 | authentication: auth 31 | }) 32 | |> Sparrow.H2Worker.Pool.Config.new() 33 | 34 | pool_1_name = pool_1_config.pool_name 35 | 36 | {:ok, _pid} = 37 | Sparrow.H2Worker.Pool.start_link(pool_1_config, :fcm, [ 38 | :alpha, 39 | :beta, 40 | :gamma 41 | ]) 42 | 43 | android_notification = 44 | Sparrow.FCM.V1.Android.new() 45 | |> Sparrow.FCM.V1.Android.add_title("title") 46 | |> Sparrow.FCM.V1.Android.add_body("body") 47 | 48 | fcm_notification = 49 | Sparrow.FCM.V1.Notification.new(:topic, "news") 50 | |> Sparrow.FCM.V1.Notification.add_android(android_notification) 51 | 52 | assert :ok == Sparrow.API.push(fcm_notification, [:alpha]) 53 | 54 | assert called Sparrow.FCM.V1.push( 55 | pool_1_name, 56 | fcm_notification, 57 | [] 58 | ) 59 | 60 | Sparrow.FCM.V1.ProjectIdBearer 61 | end 62 | end 63 | 64 | test "APNS notification is send correctly" do 65 | with_mock Sparrow.APNS, 66 | push: fn _, _, _ -> :ok end, 67 | push: fn _, _ -> :ok end, 68 | process_response: fn _ -> :ok end do 69 | Sparrow.PoolsWarden.start_link() 70 | 71 | auth = 72 | Sparrow.H2Worker.Authentication.TokenBased.new(fn -> 73 | {"authorization", "my_dummy_fcm_token"} 74 | end) 75 | 76 | pool_1_config = 77 | Sparrow.H2Worker.Config.new(%{ 78 | domain: "api.push.apple.com", 79 | port: 443, 80 | authentication: auth 81 | }) 82 | |> Sparrow.H2Worker.Pool.Config.new() 83 | 84 | pool_1_name = pool_1_config.pool_name 85 | 86 | {:ok, _pid} = 87 | Sparrow.H2Worker.Pool.start_link(pool_1_config, {:apns, :dev}, [ 88 | :alpha, 89 | :beta, 90 | :gamma 91 | ]) 92 | 93 | apns_notification = 94 | Sparrow.APNS.Notification.new("dummy token", :dev) 95 | |> Sparrow.APNS.Notification.add_title("title") 96 | |> Sparrow.APNS.Notification.add_body("body") 97 | 98 | assert :ok == Sparrow.API.push(apns_notification, [:alpha]) 99 | assert :ok == Sparrow.API.push_async(apns_notification, [:alpha]) 100 | 101 | assert called Sparrow.APNS.push(pool_1_name, apns_notification, []) 102 | 103 | assert called Sparrow.APNS.push(pool_1_name, apns_notification, [ 104 | {:is_sync, false} 105 | ]) 106 | end 107 | end 108 | 109 | test "async notification is send" do 110 | with_mock Sparrow.APNS, 111 | push: fn _, _, _ -> :ok end, 112 | push: fn _, _ -> :ok end, 113 | process_response: fn _ -> :ok end do 114 | Sparrow.PoolsWarden.start_link() 115 | 116 | auth = 117 | Sparrow.H2Worker.Authentication.TokenBased.new(fn -> 118 | {"authorization", "my_dummy_fcm_token"} 119 | end) 120 | 121 | pool_1_config = 122 | Sparrow.H2Worker.Config.new(%{ 123 | domain: "api.push.apple.com", 124 | port: 443, 125 | authentication: auth 126 | }) 127 | |> Sparrow.H2Worker.Pool.Config.new() 128 | 129 | pool_1_name = pool_1_config.pool_name 130 | 131 | {:ok, _pid} = 132 | Sparrow.H2Worker.Pool.start_link(pool_1_config, {:apns, :dev}, [ 133 | :alpha, 134 | :beta, 135 | :gamma 136 | ]) 137 | 138 | apns_notification = 139 | Sparrow.APNS.Notification.new("dummy token", :dev) 140 | |> Sparrow.APNS.Notification.add_title("title") 141 | |> Sparrow.APNS.Notification.add_body("body") 142 | 143 | assert :ok == Sparrow.API.push_async(apns_notification, [:alpha]) 144 | 145 | assert called Sparrow.APNS.push(pool_1_name, apns_notification, [ 146 | {:is_sync, false} 147 | ]) 148 | end 149 | end 150 | 151 | test "APNS pool not found" do 152 | with_mock Sparrow.PoolsWarden, 153 | choose_pool: fn _, _ -> nil end, 154 | add_new_pool: fn _, _, _, _ -> true end do 155 | auth = 156 | Sparrow.H2Worker.Authentication.TokenBased.new(fn -> 157 | {"authorization", "my_dummy_fcm_token"} 158 | end) 159 | 160 | pool_1_config = 161 | Sparrow.H2Worker.Config.new(%{ 162 | domain: "api.push.apple.com", 163 | port: 443, 164 | authentication: auth 165 | }) 166 | |> Sparrow.H2Worker.Pool.Config.new() 167 | 168 | {:ok, _pid} = 169 | Sparrow.H2Worker.Pool.start_link(pool_1_config, {:apns, :dev}, [ 170 | :alpha, 171 | :beta, 172 | :gamma 173 | ]) 174 | 175 | apns_notification = 176 | Sparrow.APNS.Notification.new("dummy token", :dev) 177 | |> Sparrow.APNS.Notification.add_title("title") 178 | |> Sparrow.APNS.Notification.add_body("body") 179 | 180 | assert {:error, :configuration_error} == 181 | Sparrow.API.push(apns_notification, [:delta]) 182 | end 183 | end 184 | 185 | test "FCM pool not found" do 186 | with_mock Sparrow.PoolsWarden, 187 | choose_pool: fn _, _ -> nil end, 188 | add_new_pool: fn _, _, _, _ -> true end do 189 | auth = 190 | Sparrow.H2Worker.Authentication.TokenBased.new(fn -> 191 | {"authorization", "my_dummy_fcm_token"} 192 | end) 193 | 194 | pool_1_config = 195 | Sparrow.H2Worker.Config.new(%{ 196 | domain: "fcm.googleapis.com", 197 | port: 443, 198 | authentication: auth 199 | }) 200 | |> Sparrow.H2Worker.Pool.Config.new() 201 | 202 | {:ok, _pid} = 203 | Sparrow.H2Worker.Pool.start_link(pool_1_config, :fcm, [ 204 | :alpha, 205 | :beta, 206 | :gamma 207 | ]) 208 | 209 | android_notification = 210 | Sparrow.FCM.V1.Android.new() 211 | |> Sparrow.FCM.V1.Android.add_title("title") 212 | |> Sparrow.FCM.V1.Android.add_body("body") 213 | 214 | fcm_notification = 215 | Sparrow.FCM.V1.Notification.new(:topic, "news", "fake_id") 216 | |> Sparrow.FCM.V1.Notification.add_android(android_notification) 217 | 218 | assert {:error, :configuration_error} == 219 | Sparrow.API.push(fcm_notification, [:alpha]) 220 | end 221 | end 222 | 223 | test "sending APNS and FCM notifications to pools with the same tags" do 224 | with_mocks([ 225 | {Sparrow.FCM.V1, [:passthrough], 226 | [ 227 | push: fn _, _, _ -> 228 | :ok 229 | end, 230 | process_response: fn _ -> :ok end 231 | ]}, 232 | {Sparrow.APNS, [:passthrough], 233 | [ 234 | push: fn _, _, _ -> :ok end, 235 | push: fn _, _ -> :ok end, 236 | process_response: fn _ -> :ok end 237 | ]} 238 | ]) do 239 | Sparrow.PoolsWarden.start_link() 240 | 241 | auth = 242 | Sparrow.H2Worker.Authentication.TokenBased.new(fn -> 243 | {"authorization", "dummy_token"} 244 | end) 245 | 246 | tags = [:alpha, :beta, :gamma] 247 | 248 | apns_pool_config = 249 | Sparrow.H2Worker.Config.new(%{ 250 | domain: "api.push.apple.com", 251 | port: 443, 252 | authentication: auth 253 | }) 254 | |> Sparrow.H2Worker.Pool.Config.new() 255 | 256 | fcm_pool_config = 257 | Sparrow.H2Worker.Config.new(%{ 258 | domain: "fcm.googleapis.com", 259 | port: 443, 260 | authentication: auth 261 | }) 262 | |> Sparrow.H2Worker.Pool.Config.new() 263 | 264 | apns_pool_name = apns_pool_config.pool_name 265 | fcm_pool_name = fcm_pool_config.pool_name 266 | 267 | {:ok, _pid} = 268 | Sparrow.H2Worker.Pool.start_link(apns_pool_config, {:apns, :dev}, tags) 269 | 270 | {:ok, _pid} = 271 | Sparrow.H2Worker.Pool.start_link(fcm_pool_config, :fcm, tags) 272 | 273 | apns_notification = 274 | Sparrow.APNS.Notification.new("dummy token", :dev) 275 | |> Sparrow.APNS.Notification.add_title("title") 276 | |> Sparrow.APNS.Notification.add_body("body") 277 | 278 | android_notification = 279 | Sparrow.FCM.V1.Android.new() 280 | |> Sparrow.FCM.V1.Android.add_title("title") 281 | |> Sparrow.FCM.V1.Android.add_body("body") 282 | 283 | fcm_notification = 284 | Sparrow.FCM.V1.Notification.new(:topic, "news") 285 | |> Sparrow.FCM.V1.Notification.add_android(android_notification) 286 | 287 | assert :ok == Sparrow.API.push(apns_notification, tags) 288 | assert :ok == Sparrow.API.push(fcm_notification, tags) 289 | 290 | assert called Sparrow.APNS.push(apns_pool_name, apns_notification, []) 291 | 292 | assert called Sparrow.FCM.V1.push( 293 | fcm_pool_name, 294 | fcm_notification, 295 | [] 296 | ) 297 | end 298 | end 299 | end 300 | -------------------------------------------------------------------------------- /test/apns/error_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.ErrorTest do 2 | use ExUnit.Case 3 | 4 | test "unmatched error" do 5 | error = :Unknown 6 | 7 | expected_description = "Unmatched error = #{inspect(error)}" 8 | 9 | actual_description = Sparrow.APNS.get_error_description(error) 10 | 11 | assert expected_description == actual_description 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/apns/manual/real_ios_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.APNS.Manual.RealIosTest do 2 | use ExUnit.Case 3 | 4 | alias Sparrow.APNS.Notification 5 | 6 | @device_token System.get_env("TOKENDEVICE") 7 | @apns_topic System.get_env("APNSTOPIC") 8 | @key_id System.get_env("KEYID") 9 | @team_id System.get_env("TEAMID") 10 | @p8_file_path "test/priv/tokens/apns_token.p8" 11 | 12 | @path_to_cert "test/priv/certs/Certificates1.pem" 13 | @path_to_key "test/priv/certs/key.pem" 14 | 15 | @title "Commander Cody" 16 | @body "the time has come. Execute order 66." 17 | 18 | @tag :skip 19 | test "real notification certificate based authentication test" do 20 | apns = [ 21 | dev: [ 22 | [ 23 | auth_type: :certificate_based, 24 | cert: @path_to_cert, 25 | key: @path_to_key 26 | ] 27 | ] 28 | ] 29 | 30 | start_sparrow_with_apns_config(apns) 31 | 32 | notification = 33 | @device_token 34 | |> Notification.new(:dev) 35 | |> Notification.add_title(@title) 36 | |> Notification.add_body(@body) 37 | |> Notification.add_apns_topic(@apns_topic) 38 | 39 | assert :ok == Sparrow.API.push(notification) 40 | end 41 | 42 | @tag :skip 43 | test "real notification token based authentication test" do 44 | apns = [ 45 | dev: [ 46 | [ 47 | auth_type: :token_based, 48 | token_id: :some_atom_id 49 | ] 50 | ], 51 | tokens: [ 52 | [ 53 | token_id: :some_atom_id, 54 | key_id: @key_id, 55 | team_id: @team_id, 56 | p8_file_path: @p8_file_path 57 | ] 58 | ] 59 | ] 60 | 61 | start_sparrow_with_apns_config(apns) 62 | 63 | notification = 64 | @device_token 65 | |> Notification.new(:dev) 66 | |> Notification.add_title(@title) 67 | |> Notification.add_body(@body) 68 | |> Notification.add_apns_topic(@apns_topic) 69 | 70 | assert :ok == Sparrow.API.push(notification) 71 | TestHelper.restore_app_env() 72 | end 73 | 74 | defp start_sparrow_with_apns_config(config) do 75 | Application.stop(:sparrow) 76 | Application.put_env(:sparrow, :apns, config) 77 | Application.start(:sparrow) 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/apns/response_processing_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.APNS.ResponseProcessingTest do 2 | use ExUnit.Case 3 | 4 | import Mox 5 | setup :set_mox_global 6 | setup :verify_on_exit! 7 | 8 | import Helpers.SetupHelper, only: [passthrough_h2: 1] 9 | setup :passthrough_h2 10 | 11 | test "push response is handled correctly with single header" do 12 | headers = [{":status", "200"}] 13 | body = "" 14 | 15 | assert :ok == Sparrow.APNS.process_response({:ok, {headers, body}}) 16 | end 17 | 18 | test "push response is handled correctly with single multiple headers, status first" do 19 | headers = [ 20 | {":status", "200"}, 21 | {"some header", "200"}, 22 | {"another header", "value"} 23 | ] 24 | 25 | body = "" 26 | 27 | assert :ok == Sparrow.APNS.process_response({:ok, {headers, body}}) 28 | end 29 | 30 | test "push response is handled correctly with single header, status not first" do 31 | headers = [ 32 | {"some header", "200"}, 33 | {"another header", "value"}, 34 | {":status", "200"} 35 | ] 36 | 37 | body = "" 38 | 39 | assert :ok == Sparrow.APNS.process_response({:ok, {headers, body}}) 40 | end 41 | 42 | test "push response is handled correctly with single header, no status header" do 43 | headers = [{"some header", "200"}, {"another header", "value"}] 44 | body = "{\"reason\" : \"MyReason\"}" 45 | 46 | assert {:error, :MyReason} == 47 | Sparrow.APNS.process_response({:ok, {headers, body}}) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/apns/token_bearer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.APNS.TokenBearerTest do 2 | use ExUnit.Case 3 | 4 | import Mox 5 | setup :set_mox_global 6 | setup :verify_on_exit! 7 | 8 | @key_id "KEYID" 9 | @team_id "TEAMID" 10 | @p8_file_path "token.p8" 11 | @refresh_time 100 12 | @token_id :token_id 13 | 14 | import Helpers.SetupHelper, only: [passthrough_h2: 1] 15 | setup :passthrough_h2 16 | 17 | setup do 18 | config = %{ 19 | @token_id => 20 | Sparrow.APNS.Token.new( 21 | @key_id, 22 | @team_id, 23 | @p8_file_path 24 | ) 25 | } 26 | 27 | state = %Sparrow.APNS.TokenBearer.State{ 28 | tokens: %{ 29 | @token_id => 30 | Sparrow.APNS.Token.new( 31 | @key_id, 32 | @team_id, 33 | @p8_file_path 34 | ) 35 | }, 36 | update_token_after: @refresh_time 37 | } 38 | 39 | {:ok, pid} = 40 | GenServer.start_link(Sparrow.APNS.TokenBearer, {config, @refresh_time}) 41 | 42 | {:ok, config: config, token_bearer_pid: pid, state: state} 43 | end 44 | 45 | test "token gets refresh time correctly", context do 46 | refresh_time = 47 | context[:token_bearer_pid] 48 | |> :sys.get_state() 49 | |> Map.get(:update_token_after) 50 | 51 | assert refresh_time == @refresh_time 52 | end 53 | 54 | test "token gets updated" do 55 | token_before_update = Sparrow.APNS.TokenBearer.get_token(@token_id) 56 | :timer.sleep(200) 57 | token_after_update = Sparrow.APNS.TokenBearer.get_token(@token_id) 58 | 59 | assert token_before_update != token_after_update 60 | assert is_binary(token_before_update) 61 | assert is_binary(token_after_update) 62 | end 63 | 64 | test "token bearer is initialized correctly", context do 65 | assert context[:state] == :sys.get_state(context[:token_bearer_pid]) 66 | end 67 | 68 | test "token bearer ignores unknown messages", context do 69 | before_unexpected_message_state = :sys.get_state(context[:token_bearer_pid]) 70 | send(context[:token_bearer_pid], :unknown) 71 | 72 | assert before_unexpected_message_state == 73 | :sys.get_state(context[:token_bearer_pid]) 74 | end 75 | 76 | test "terminate deletes ets table", context do 77 | Process.unlink(context[:token_bearer_pid]) 78 | Process.exit(context[:token_bearer_pid], :kill) 79 | wait_for_proccess_to_die(context[:token_bearer_pid]) 80 | assert :undefined == :ets.info(:sparrow_apns_token_bearer) 81 | end 82 | 83 | test "init, terminate deletes ets table", context do 84 | Process.unlink(context[:token_bearer_pid]) 85 | Process.exit(context[:token_bearer_pid], :kill) 86 | wait_for_proccess_to_die(context[:token_bearer_pid]) 87 | assert :undefined == :ets.info(:sparrow_apns_token_bearer) 88 | Sparrow.APNS.TokenBearer.init(context[:config]) 89 | Sparrow.APNS.TokenBearer.terminate(:any, context[:state]) 90 | 91 | assert :undefined == :ets.info(:sparrow_apns_token_bearer) 92 | end 93 | 94 | defp wait_for_proccess_to_die(pid) do 95 | case Process.alive?(pid) do 96 | true -> 97 | :timer.sleep(10) 98 | wait_for_proccess_to_die(pid) 99 | 100 | _ -> 101 | :oik 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/fcm/manual/real_android_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.Manual.RealAndroidTest do 2 | use ExUnit.Case 3 | 4 | alias Sparrow.FCM.V1.Notification 5 | 6 | @project_id "sparrow-2b961" 7 | @target_type :topic 8 | @target "news" 9 | 10 | @notification_title "Commander Cody" 11 | @notification_body "the time has come. Execute order 66." 12 | 13 | @android_title "Real life" 14 | @android_body "never heard of that server" 15 | @path_to_json "priv/fcm/token/sparrow_token.json" 16 | 17 | @tag :skip 18 | test "real android notification send" do 19 | fcm = [ 20 | [ 21 | path_to_json: @path_to_json 22 | ] 23 | ] 24 | 25 | start_sparrow_with_fcm_config(fcm) 26 | 27 | android = 28 | Sparrow.FCM.V1.Android.new() 29 | |> Sparrow.FCM.V1.Android.add_title(@android_title) 30 | |> Sparrow.FCM.V1.Android.add_body(@android_body) 31 | 32 | notification = 33 | @target_type 34 | |> Notification.new( 35 | @target, 36 | @project_id, 37 | @notification_title, 38 | @notification_body 39 | ) 40 | |> Notification.add_android(android) 41 | 42 | assert :ok == Sparrow.API.push(notification) 43 | TestHelper.restore_app_env() 44 | end 45 | 46 | defp start_sparrow_with_fcm_config(config) do 47 | Application.stop(:sparrow) 48 | Application.put_env(:sparrow, :fcm, config) 49 | Application.start(:sparrow) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/fcm/manual/real_webpush_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.Manual.RealWebpushTest do 2 | use ExUnit.Case 3 | 4 | alias Sparrow.FCM.V1.Notification 5 | 6 | @project_id "sparrow-2b961" 7 | @webpush_title "TORA" 8 | @webpush_body "TORA TORA" 9 | # get token from browser 10 | @webpush_target_type :token 11 | @webpush_target "dummy" 12 | @path_to_json "priv/fcm/token/sparrow_token.json" 13 | 14 | @tag :skip 15 | test "real webpush notification send" do 16 | fcm = [ 17 | [ 18 | path_to_json: @path_to_json 19 | ] 20 | ] 21 | 22 | start_sparrow_with_fcm_config(fcm) 23 | 24 | webpush = 25 | Sparrow.FCM.V1.Webpush.new("www.google.com") 26 | |> Sparrow.FCM.V1.Webpush.add_title(@webpush_title) 27 | |> Sparrow.FCM.V1.Webpush.add_body(@webpush_body) 28 | 29 | notification = 30 | @webpush_target_type 31 | |> Notification.new( 32 | @webpush_target, 33 | @project_id 34 | ) 35 | |> Notification.add_webpush(webpush) 36 | 37 | assert :ok == Sparrow.API.push(notification) 38 | TestHelper.restore_app_env() 39 | end 40 | 41 | defp start_sparrow_with_fcm_config(config) do 42 | Application.stop(:sparrow) 43 | Application.put_env(:sparrow, :fcm, config) 44 | Application.start(:sparrow) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/fcm/v1/android_config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.AndroidTest do 2 | use ExUnit.Case 3 | 4 | alias Sparrow.FCM.V1.Android 5 | 6 | @collapse_key "colllapse key" 7 | @priority :NORMAL 8 | @ttl 1234 9 | @restricted "restricted package name" 10 | @data %{:keyA => :valueA, :keyB => :valueB} 11 | 12 | @title "the lord of the rings" 13 | @body "two towers" 14 | @icon "sauron.jpg" 15 | @color "green" 16 | @sound "tum tum tum" 17 | @tag_field "hobbit" 18 | @click_action "destroy gondor" 19 | @body_loc_key "armour" 20 | @body_loc_args "mithril" 21 | @title_loc_key "moria" 22 | @title_loc_args "barlog" 23 | 24 | test "android config field are added" do 25 | config = 26 | Android.new() 27 | |> Android.add_collapse_key(@collapse_key) 28 | |> Android.add_priority(@priority) 29 | |> Android.add_ttl(@ttl) 30 | |> Android.add_restricted_package_name(@restricted) 31 | |> Android.add_data(@data) 32 | 33 | assert {:collapse_key, @collapse_key} in config.fields 34 | assert {:priority, @priority} in config.fields 35 | assert {:ttl, Integer.to_string(@ttl) <> "s"} in config.fields 36 | assert {:restricted_package_name, @restricted} in config.fields 37 | assert {:data, @data} in config.fields 38 | assert Enum.empty?(config.notification.fields) 39 | end 40 | 41 | test "android notification field are added" do 42 | config = 43 | Android.new() 44 | |> Android.add_title(@title) 45 | |> Android.add_body(@body) 46 | |> Android.add_icon(@icon) 47 | |> Android.add_color(@color) 48 | |> Android.add_sound(@sound) 49 | |> Android.add_tag(@tag_field) 50 | |> Android.add_click_action(@click_action) 51 | |> Android.add_body_loc_key(@body_loc_key) 52 | |> Android.add_body_loc_args(@body_loc_args) 53 | |> Android.add_title_loc_key(@title_loc_key) 54 | |> Android.add_title_loc_args(@title_loc_args) 55 | 56 | assert {:title, @title} in config.notification.fields 57 | assert {:body, @body} in config.notification.fields 58 | assert {:icon, @icon} in config.notification.fields 59 | assert {:color, @color} in config.notification.fields 60 | assert {:sound, @sound} in config.notification.fields 61 | assert {:tag, @tag_field} in config.notification.fields 62 | assert {:click_action, @click_action} in config.notification.fields 63 | assert {:body_loc_key, @body_loc_key} in config.notification.fields 64 | assert {:"body_loc_args[]", @body_loc_args} in config.notification.fields 65 | assert {:title_loc_key, @title_loc_key} in config.notification.fields 66 | assert {:"title_loc_args[]", @title_loc_args} in config.notification.fields 67 | assert Enum.empty?(config.fields) 68 | end 69 | 70 | test "android config, unknown prioirty" do 71 | assert_raise FunctionClauseError, fn -> 72 | Android.new() 73 | |> Android.add_priority(:LOW) 74 | end 75 | end 76 | 77 | test "android config, sets HIGH prioirty" do 78 | config = 79 | Android.new() 80 | |> Android.add_priority(:HIGH) 81 | 82 | assert {:priority, :HIGH} in config.fields 83 | assert 1 == Enum.count(config.fields) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/fcm/v1/apns_config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.APNSTest do 2 | use ExUnit.Case 3 | 4 | alias Sparrow.APNS.Notification 5 | alias Sparrow.FCM.V1.APNS 6 | 7 | test "apns config is created correcly" do 8 | token_getter = fn -> {"authorization", "Bearer dummy token"} end 9 | 10 | apns_notification = 11 | "dummy device token" 12 | |> Notification.new(:dev) 13 | |> Notification.add_title("apns title") 14 | |> Notification.add_body("apns body") 15 | 16 | apns = APNS.new(apns_notification, token_getter) 17 | 18 | assert token_getter == apns.token_getter 19 | assert apns_notification == apns.notification 20 | end 21 | 22 | test "apns config is built correcly" do 23 | token_getter = fn -> {"authorization", "Bearer dummy token"} end 24 | 25 | apns_notification = 26 | "dummy device token" 27 | |> Notification.new(:dev) 28 | |> Notification.add_title("apns title") 29 | |> Notification.add_body("apns body") 30 | |> Notification.add_apns_id("apns id") 31 | 32 | apns_config = 33 | APNS.new(apns_notification, token_getter) 34 | |> APNS.to_map() 35 | 36 | assert %{headers: headers, payload: payload} = apns_config 37 | assert headers["apns-id"] == "apns id" 38 | assert headers["authorization"] == "Bearer dummy token" 39 | 40 | assert %{"aps" => %{"alert" => %{title: "apns title", body: "apns body"}}} == 41 | payload 42 | end 43 | 44 | test "apns config is created correcly without token getter" do 45 | apns_notification = 46 | "dummy device token" 47 | |> Notification.new(:dev) 48 | |> Notification.add_title("apns title") 49 | |> Notification.add_body("apns body") 50 | 51 | apns = APNS.new(apns_notification) 52 | 53 | assert nil == apns.token_getter 54 | assert apns_notification == apns.notification 55 | end 56 | 57 | test "apns config is built correcly without token getter" do 58 | apns_notification = 59 | "dummy device token" 60 | |> Notification.new(:dev) 61 | |> Notification.add_title("apns title") 62 | |> Notification.add_body("apns body") 63 | |> Notification.add_apns_id("apns id") 64 | 65 | apns_config = 66 | APNS.new(apns_notification) 67 | |> APNS.to_map() 68 | 69 | assert %{headers: headers, payload: payload} = apns_config 70 | assert headers["apns-id"] == "apns id" 71 | assert headers["authorization"] == nil 72 | 73 | assert %{"aps" => %{"alert" => %{title: "apns title", body: "apns body"}}} == 74 | payload 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/fcm/v1/notification_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.NotificationTest do 2 | use ExUnit.Case 3 | 4 | alias Sparrow.APNS.Notification, as: APNSNotification 5 | alias Sparrow.FCM.V1.Android 6 | alias Sparrow.FCM.V1.APNS 7 | alias Sparrow.FCM.V1.Webpush 8 | alias Sparrow.FCM.V1.Notification 9 | 10 | @title "test title" 11 | @body "test body" 12 | @target "test target" 13 | @data %{:keyA => :valueA, :B => :b} 14 | 15 | test "notification without any config" do 16 | fcm_notification = 17 | Sparrow.FCM.V1.Notification.new( 18 | :token, 19 | @target, 20 | @title, 21 | @body, 22 | @data 23 | ) 24 | 25 | assert fcm_notification.project_id == nil 26 | assert fcm_notification.title == @title 27 | assert fcm_notification.body == @body 28 | assert fcm_notification.target == @target 29 | assert fcm_notification.android == nil 30 | assert fcm_notification.webpush == nil 31 | assert fcm_notification.apns == nil 32 | assert fcm_notification.data == @data 33 | end 34 | 35 | test "notification default values are set correctly" do 36 | notification = Notification.new(:token, "dummy token") 37 | 38 | assert notification.title == nil 39 | assert notification.body == nil 40 | assert notification.data == %{} 41 | end 42 | 43 | test "notification default values are set but to default value" do 44 | notification = Notification.new(:token, "dummy token", nil, nil, %{}) 45 | 46 | assert notification.title == nil 47 | assert notification.body == nil 48 | assert notification.data == %{} 49 | end 50 | 51 | test "notification non default values are set correctly" do 52 | notification = 53 | Notification.new( 54 | :token, 55 | "dummy token", 56 | @title, 57 | @body, 58 | @data 59 | ) 60 | 61 | assert notification.title == @title 62 | assert notification.body == @body 63 | assert notification.data == @data 64 | end 65 | 66 | test "notification with android config" do 67 | android = 68 | Android.new() 69 | |> Android.add_collapse_key("collapse_key") 70 | |> Android.add_color("color") 71 | 72 | fcm_notification = 73 | Sparrow.FCM.V1.Notification.new( 74 | :token, 75 | @target, 76 | nil, 77 | nil, 78 | @data 79 | ) 80 | |> Notification.add_android(android) 81 | 82 | assert fcm_notification.project_id == nil 83 | assert fcm_notification.title == nil 84 | assert fcm_notification.body == nil 85 | assert fcm_notification.target == @target 86 | assert fcm_notification.android == android 87 | assert fcm_notification.webpush == nil 88 | assert fcm_notification.apns == nil 89 | assert fcm_notification.data == @data 90 | end 91 | 92 | test "notification with webpush config" do 93 | webpush = Webpush.new("link") 94 | 95 | fcm_notification = 96 | Sparrow.FCM.V1.Notification.new( 97 | :token, 98 | @target, 99 | @title, 100 | @body, 101 | @data 102 | ) 103 | |> Notification.add_webpush(webpush) 104 | 105 | assert fcm_notification.project_id == nil 106 | assert fcm_notification.title == @title 107 | assert fcm_notification.body == @body 108 | assert fcm_notification.target == @target 109 | assert fcm_notification.android == nil 110 | assert fcm_notification.webpush == webpush 111 | assert fcm_notification.apns == nil 112 | assert fcm_notification.data == @data 113 | end 114 | 115 | test "notification with apns config" do 116 | apns_notification = 117 | "dummy device token" 118 | |> APNSNotification.new(:dev) 119 | |> APNSNotification.add_title("apns title") 120 | |> APNSNotification.add_body("apns body") 121 | 122 | apns = 123 | APNS.new(apns_notification, fn -> 124 | {"authorization", "Bearer dummy token"} 125 | end) 126 | 127 | fcm_notification = 128 | Sparrow.FCM.V1.Notification.new( 129 | :token, 130 | @target, 131 | @title, 132 | @body, 133 | @data 134 | ) 135 | |> Notification.add_apns(apns) 136 | 137 | assert fcm_notification.project_id == nil 138 | assert fcm_notification.title == @title 139 | assert fcm_notification.body == @body 140 | assert fcm_notification.target == @target 141 | assert fcm_notification.android == nil 142 | assert fcm_notification.webpush == nil 143 | assert fcm_notification.apns == apns 144 | assert fcm_notification.data == @data 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /test/fcm/v1/token_bearer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.TokenBearerTest do 2 | use ExUnit.Case 3 | 4 | import Mock 5 | import Mox 6 | setup :set_mox_global 7 | setup :verify_on_exit! 8 | 9 | @google_json_path "./sparrow_token.json" 10 | 11 | @token "dummy token" 12 | @expires 1_234_567 13 | 14 | import Helpers.SetupHelper, only: [passthrough_h2: 1] 15 | setup :passthrough_h2 16 | 17 | test "token bearer gets token" do 18 | with_mock Goth, 19 | fetch: fn _name -> 20 | {:ok, 21 | %Goth.Token{ 22 | expires: @expires, 23 | scope: "https://www.googleapis.com/auth/firebase.messaging", 24 | sub: nil, 25 | token: @token, 26 | type: "Bearer" 27 | }} 28 | end do 29 | assert @token == Sparrow.FCM.V1.TokenBearer.get_token("") 30 | end 31 | end 32 | 33 | test "token bearer starts" do 34 | config = [[{:path_to_json, @google_json_path}]] 35 | 36 | {:ok, pid} = 37 | start_supervised(%{ 38 | id: Sparrow.FCM.V1.TokenBearer, 39 | start: {Sparrow.FCM.V1.TokenBearer, :start_link, [config]} 40 | }) 41 | 42 | assert is_pid(pid) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/fcm/v1/webpush_config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.FCM.V1.WebpushTest do 2 | use ExUnit.Case 3 | 4 | alias Sparrow.FCM.V1.Webpush 5 | 6 | @link "test link" 7 | @web_push_data %{:key1 => :value1, :key2 => :value2} 8 | @web_notification_data %{:keyA => :valueA, :keyB => :valueB} 9 | @header_key "header_key" 10 | @header_value "header_value" 11 | 12 | test "webpush config is built correcly" do 13 | actions = "test actions" 14 | badge = "test badge" 15 | body = "test body" 16 | dir = "test dir" 17 | lang = "test lang" 18 | tag = "test tag" 19 | icon = "test icon" 20 | image = "test image" 21 | renotify = "test renotify" 22 | silent = "test silent" 23 | timestamp = "test time" 24 | title = "test title" 25 | vibrate = "test vibrate" 26 | 27 | webpush = 28 | Webpush.new(@link, @web_push_data) 29 | |> Webpush.add_header(@header_key, @header_value) 30 | |> Webpush.add_web_notification_data(@web_notification_data) 31 | |> Webpush.add_permission(:granted) 32 | |> Webpush.add_actions(actions) 33 | |> Webpush.add_badge(badge) 34 | |> Webpush.add_body(body) 35 | |> Webpush.add_dir(dir) 36 | |> Webpush.add_lang(lang) 37 | |> Webpush.add_tag(tag) 38 | |> Webpush.add_icon(icon) 39 | |> Webpush.add_image(image) 40 | |> Webpush.add_renotify(renotify) 41 | |> Webpush.add_require_interaction(true) 42 | |> Webpush.add_silent(silent) 43 | |> Webpush.add_timestamp(timestamp) 44 | |> Webpush.add_title(title) 45 | |> Webpush.add_vibrate(vibrate) 46 | 47 | assert [{@header_key, @header_value}] == webpush.headers 48 | assert {:permission, :granted} in webpush.web_notification.fields 49 | assert {:actions, actions} in webpush.web_notification.fields 50 | assert {:badge, badge} in webpush.web_notification.fields 51 | assert {:body, body} in webpush.web_notification.fields 52 | assert {:dir, dir} in webpush.web_notification.fields 53 | assert {:lang, lang} in webpush.web_notification.fields 54 | assert {:tag, tag} in webpush.web_notification.fields 55 | assert {:icon, icon} in webpush.web_notification.fields 56 | assert {:image, image} in webpush.web_notification.fields 57 | assert {:renotify, renotify} in webpush.web_notification.fields 58 | assert {:silent, silent} in webpush.web_notification.fields 59 | assert {:timestamp, timestamp} in webpush.web_notification.fields 60 | assert {:title, title} in webpush.web_notification.fields 61 | assert {:vibrate, vibrate} in webpush.web_notification.fields 62 | assert @web_push_data == webpush.data 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/h2_client_adapter/chatterbox_test.exs: -------------------------------------------------------------------------------- 1 | defmodule H2ClientAdapter.ChatterboxTest do 2 | use ExUnit.Case 3 | use Quixir 4 | use AssertEventually, timeout: 5000, interval: 10 5 | 6 | import Mock 7 | import Mox 8 | setup :set_mox_global 9 | setup :verify_on_exit! 10 | 11 | alias Sparrow.H2ClientAdapter.Chatterbox, as: H2Adapter 12 | 13 | doctest H2Adapter 14 | 15 | @repeats 10 16 | 17 | import Helpers.SetupHelper, only: [passthrough_h2: 1] 18 | setup :passthrough_h2 19 | 20 | test "open connection" do 21 | with_mock :h2_client, start: fn _, _, _, _ -> {:ok, self()} end do 22 | assert {:ok, self()} === H2Adapter.open("my.domain.at.domain", 1234, []) 23 | 24 | assert called :h2_client.start( 25 | :https, 26 | ~c"my.domain.at.domain", 27 | 1234, 28 | [] 29 | ) 30 | end 31 | end 32 | 33 | test "open connection different domain adreses and ports and extra options succesfully" do 34 | with_mock :h2_client, start: fn _, _, _, _ -> {:ok, self()} end do 35 | ptest [ 36 | domain: string(min: 5, max: 20, chars: ?a..?z), 37 | port: int(min: 0, max: 65_535), 38 | options: list(of: atom(), min: 2, max: 10) 39 | ], 40 | repeat_for: @repeats do 41 | assert {:ok, self()} === H2Adapter.open(domain, port, options) 42 | 43 | assert called :h2_client.start( 44 | :https, 45 | to_charlist(domain), 46 | port, 47 | options 48 | ) 49 | end 50 | end 51 | end 52 | 53 | test "open connection different domain adreses and ports and extra option returning ignore" do 54 | with_mock :h2_client, start: fn _, _, _, _ -> :ignore end do 55 | ptest [ 56 | domain: string(min: 5, max: 20, chars: ?a..?z), 57 | port: int(min: 0, max: 65_535), 58 | options: list(of: atom(), min: 2, max: 10) 59 | ], 60 | repeat_for: @repeats do 61 | assert {:error, :ignore} === H2Adapter.open(domain, port, options) 62 | 63 | assert called :h2_client.start( 64 | :https, 65 | to_charlist(domain), 66 | port, 67 | options 68 | ) 69 | end 70 | end 71 | end 72 | 73 | test "open connection different domain adreses and ports and extra option returning error with reason" do 74 | ptest [ 75 | domain: string(min: 5, max: 20, chars: ?a..?z), 76 | reason: string(min: 5, max: 20, chars: ?a..?z), 77 | port: int(min: 0, max: 65_535), 78 | options: list(of: atom(), min: 2, max: 10) 79 | ], 80 | repeat_for: @repeats do 81 | with_mock :h2_client, start: fn _, _, _, _ -> {:error, reason} end do 82 | assert {:error, reason} === H2Adapter.open(domain, port, options) 83 | 84 | assert called :h2_client.start( 85 | :https, 86 | to_charlist(domain), 87 | port, 88 | options 89 | ) 90 | end 91 | end 92 | end 93 | 94 | test "close connection" do 95 | with_mock :h2_client, stop: fn _ -> :ok end do 96 | assert :ok === H2Adapter.close(self()) 97 | assert called :h2_client.stop(self()) 98 | end 99 | end 100 | 101 | test "sending post request returning error" do 102 | ptest [ 103 | reason: string(min: 5, max: 20, chars: ?a..?z), 104 | domain: string(min: 5, max: 20, chars: ?a..?z), 105 | path: string(min: 3, max: 15, chars: :ascii), 106 | headersA1: list(of: string(), min: 2, max: 2, chars: :ascii), 107 | headersB1: list(of: string(), min: 2, max: 2, chars: :ascii), 108 | body: string(min: 3, max: 15, chars: :ascii) 109 | ], 110 | repeat_for: @repeats do 111 | with_mock :h2_connection, new_stream: fn _ -> {:error, reason} end do 112 | conn = self() 113 | headers = Enum.zip([headersA1, headersB1]) 114 | 115 | assert {:error, reason} === 116 | H2Adapter.post(conn, domain, path, headers, body) 117 | 118 | assert called :h2_connection.new_stream(conn) 119 | end 120 | end 121 | end 122 | 123 | test "sending post request returning stream_id" do 124 | ptest [ 125 | headersA1: list(of: string(), min: 2, max: 2, chars: :ascii), 126 | headersB1: list(of: string(), min: 2, max: 2, chars: :ascii), 127 | domain: string(min: 5, max: 20, chars: ?a..?z), 128 | path: string(min: 3, max: 15, chars: :ascii), 129 | body: string(min: 3, max: 15, chars: :ascii), 130 | stream_id: int(min: 0, max: 65_535) 131 | ], 132 | repeat_for: @repeats do 133 | conn = pid("0.2.3") 134 | headers = Enum.zip([headersA1, headersB1]) 135 | 136 | with_mock :h2_connection, 137 | new_stream: fn _ -> stream_id end, 138 | send_headers: fn _, _, _ -> :ok end, 139 | send_body: fn _, _, _ -> :ok end do 140 | assert {:ok, stream_id} === 141 | H2Adapter.post(conn, domain, path, headers, body) 142 | 143 | args = 144 | :h2_connection 145 | |> :meck.history() 146 | |> Enum.find(fn 147 | {_, {:h2_connection, :send_headers, _}, _} -> true 148 | _ -> false 149 | end) 150 | |> (fn {_, {_, _, [_, _, args]}, _} -> args end).() 151 | 152 | assert not Enum.empty?(args) 153 | assert {":scheme", "https"} in args 154 | assert {":authority", domain} in args 155 | assert {":path", path} in args 156 | assert {":method", "POST"} in args 157 | assert {"content-length", "#{byte_size(body)}"} in args 158 | assert Enum.all?(headers, fn elem -> elem in args end) 159 | assert called :h2_connection.send_body(conn, stream_id, body) 160 | end 161 | end 162 | end 163 | 164 | test " succesfully getting response from get_response" do 165 | ptest [ 166 | headers: list(of: string(), min: 2, max: 20, chars: :ascii), 167 | body: string(min: 3, max: 15, chars: :ascii), 168 | stream_id: int(min: 0, max: 65_535) 169 | ], 170 | repeat_for: @repeats do 171 | conn = pid("0.2.3") 172 | 173 | with_mock :h2_connection, 174 | get_response: fn _, _ -> {:ok, {headers, body}} end do 175 | assert {:ok, {headers, body}} == H2Adapter.get_response(conn, stream_id) 176 | 177 | assert called :h2_connection.get_response(conn, stream_id) 178 | end 179 | end 180 | end 181 | 182 | test "get_response timeouting" do 183 | ptest [ 184 | stream_id: int(min: 0, max: 65_535) 185 | ], 186 | repeat_for: @repeats do 187 | conn = pid("0.2.3") 188 | 189 | with_mock :h2_connection, 190 | get_response: fn _, _ -> :not_ready end do 191 | assert {:error, :not_ready} == H2Adapter.get_response(conn, stream_id) 192 | 193 | assert called :h2_connection.get_response(conn, stream_id) 194 | end 195 | end 196 | end 197 | 198 | test "ping" do 199 | conn = pid("0.2.3") 200 | 201 | with_mock :h2_client, 202 | send_ping: fn _ -> :ok end do 203 | assert :ok === H2Adapter.ping(conn) 204 | assert called :h2_client.send_ping(conn) 205 | end 206 | end 207 | 208 | defp pid(string) when is_binary(string) do 209 | :erlang.list_to_pid(~c"<#{string}>") 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /test/h2_integration/certificate_rejected_test.exs: -------------------------------------------------------------------------------- 1 | defmodule H2Integration.CerificateRejectedTest do 2 | use ExUnit.Case 3 | 4 | import Mox 5 | setup :set_mox_global 6 | setup :verify_on_exit! 7 | 8 | alias Helpers.SetupHelper, as: Setup 9 | alias Sparrow.H2Worker.Request, as: OuterRequest 10 | 11 | import Helpers.SetupHelper, only: [passthrough_h2: 1] 12 | setup :passthrough_h2 13 | 14 | setup_all do 15 | {:ok, _cowboy_pid, cowboys_name} = 16 | [ 17 | {":_", 18 | [ 19 | {"/RejectCertificateHandler", 20 | Helpers.CowboyHandlers.RejectCertificateHandler, []} 21 | ]} 22 | ] 23 | |> :cowboy_router.compile() 24 | |> Setup.start_cowboy_tls( 25 | certificate_required: :negative_cerificate_verification 26 | ) 27 | 28 | on_exit(fn -> 29 | :cowboy.stop_listener(cowboys_name) 30 | end) 31 | 32 | {:ok, port: :ranch.get_port(cowboys_name)} 33 | end 34 | 35 | test "cowboy does not accept certificate", context do 36 | config = Setup.create_h2_worker_config(Setup.server_host(), context[:port]) 37 | 38 | headers = Setup.default_headers() 39 | body = "sound of silence, test body" 40 | 41 | request = 42 | OuterRequest.new(headers, body, "/RejectCertificateHandler", 3_000) 43 | 44 | worker_pid = start_supervised!(Setup.h2_worker_spec(config)) 45 | 46 | assert {:error, reason} = 47 | GenServer.call(worker_pid, {:send_request, request}) 48 | 49 | case reason do 50 | {:unable_to_connect, {:tls_alert, ~c"bad certificate"}} -> :ok 51 | {:unable_to_connect, {:tls_alert, {:bad_certificate, _}}} -> :ok 52 | :connection_lost -> :ok 53 | _ -> flunk("Wrong error code: #{inspect(reason)}") 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/h2_integration/certificate_required_test.exs: -------------------------------------------------------------------------------- 1 | defmodule H2Integration.CerificateRequiredTest do 2 | use ExUnit.Case 3 | 4 | alias Helpers.SetupHelper, as: Setup 5 | alias Sparrow.H2Worker.Request, as: OuterRequest 6 | alias Sparrow.APNS.Notification 7 | 8 | @cert_path "priv/ssl/client_cert.pem" 9 | @key_path "priv/ssl/client_key.pem" 10 | 11 | import Mox 12 | setup :set_mox_global 13 | setup :verify_on_exit! 14 | 15 | import Helpers.SetupHelper, only: [passthrough_h2: 1] 16 | setup :passthrough_h2 17 | 18 | setup do 19 | {:ok, _cowboy_pid, cowboys_name} = 20 | [ 21 | {":_", 22 | [ 23 | {"/EchoClientCerificateHandler", 24 | Helpers.CowboyHandlers.EchoClientCerificateHandler, []} 25 | ]} 26 | ] 27 | |> :cowboy_router.compile() 28 | |> Setup.start_cowboy_tls( 29 | certificate_required: :positive_cerificate_verification 30 | ) 31 | 32 | on_exit(fn -> 33 | :cowboy.stop_listener(cowboys_name) 34 | end) 35 | 36 | {:ok, port: :ranch.get_port(cowboys_name)} 37 | end 38 | 39 | @pool_name :pool 40 | test "cowboy replies with sent cerificate", context do 41 | auth = 42 | Sparrow.H2Worker.Authentication.CertificateBased.new( 43 | @cert_path, 44 | @key_path 45 | ) 46 | 47 | config = 48 | Sparrow.H2Worker.Config.new(%{ 49 | domain: Setup.server_host(), 50 | port: context[:port], 51 | authentication: auth, 52 | tls_options: [verify: :verify_none] 53 | }) 54 | 55 | headers = Setup.default_headers() 56 | body = "body" 57 | 58 | request = 59 | OuterRequest.new(headers, body, "/EchoClientCerificateHandler", 2_000) 60 | 61 | Sparrow.H2Worker.Pool.Config.new(config, @pool_name) 62 | |> Sparrow.H2Worker.Pool.start_unregistered(:fcm, []) 63 | 64 | {:ok, {answer_headers, answer_body}} = 65 | Sparrow.H2Worker.Pool.send_request(@pool_name, request) 66 | 67 | {:ok, pem_bin} = File.read(@cert_path) 68 | 69 | expected_subject = 70 | Helpers.CerificateHelper.get_subject_name_form_not_encoded_cert(pem_bin) 71 | 72 | assert_response_header(answer_headers, {":status", "200"}) 73 | assert expected_subject == answer_body 74 | end 75 | 76 | test "worker rejects cowboy cerificate", context do 77 | auth = 78 | Sparrow.H2Worker.Authentication.CertificateBased.new( 79 | @cert_path, 80 | @key_path 81 | ) 82 | 83 | config = 84 | Sparrow.H2Worker.Config.new(%{ 85 | domain: Setup.server_host(), 86 | port: context[:port], 87 | authentication: auth, 88 | tls_options: [ 89 | {:verify, :verify_peer} 90 | ], 91 | ping_interval: 10_000 92 | }) 93 | 94 | notification = 95 | "OkResponseHandler" 96 | |> Notification.new(:dev) 97 | |> Notification.add_title("") 98 | |> Notification.add_body("") 99 | 100 | worker_pid = start_supervised!(Setup.h2_worker_spec(config)) 101 | 102 | assert {:error, {:unable_to_connect, reason}} = 103 | GenServer.call(worker_pid, {:send_request, notification}) 104 | 105 | assert Enum.member?( 106 | [ 107 | # OTP 25 and below 108 | {:options, {:cacertfile, []}}, 109 | # OTP 26+ 110 | {:options, :incompatible, 111 | [verify: :verify_peer, cacerts: :undefined]} 112 | ], 113 | reason 114 | ) 115 | end 116 | 117 | defp assert_response_header(headers, expected_header) do 118 | assert Enum.any?(headers, &(&1 == expected_header)) 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/h2_integration/client_server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule H2Integration.ClientServerTest do 2 | import Mock 3 | 4 | use ExUnit.Case 5 | 6 | alias Helpers.SetupHelper, as: Setup 7 | alias Sparrow.H2ClientAdapter.Chatterbox, as: H2Adapter 8 | alias Sparrow.H2Worker.Request, as: OuterRequest 9 | 10 | @body "test body" 11 | @pool_name :name 12 | 13 | import Mox 14 | setup :set_mox_global 15 | setup :verify_on_exit! 16 | 17 | import Helpers.SetupHelper, only: [passthrough_h2: 1] 18 | setup :passthrough_h2 19 | 20 | setup do 21 | {:ok, cowboy_pid, cowboys_name} = 22 | [ 23 | {":_", 24 | [ 25 | {"/ConnTestHandler", Helpers.CowboyHandlers.ConnectionHandler, []}, 26 | {"/HeaderToBodyEchoHandler", 27 | Helpers.CowboyHandlers.HeaderToBodyEchoHandler, []}, 28 | {"/TimeoutHandler", Helpers.CowboyHandlers.TimeoutHandler, []} 29 | ]} 30 | ] 31 | |> :cowboy_router.compile() 32 | |> Setup.start_cowboy_tls(certificate_required: :no) 33 | 34 | on_exit(fn -> 35 | case Process.alive?(cowboy_pid) do 36 | true -> :cowboy.stop_listener(cowboys_name) 37 | _ -> :ok 38 | end 39 | end) 40 | 41 | {:ok, port: :ranch.get_port(cowboys_name)} 42 | end 43 | 44 | test "cowboy echos headers in body", context do 45 | config = Setup.create_h2_worker_config(Setup.server_host(), context[:port]) 46 | 47 | headers = [ 48 | {"my_cool_header", "my_even_cooler_value"} | Setup.default_headers() 49 | ] 50 | 51 | Sparrow.H2Worker.Pool.Config.new(config, @pool_name) 52 | |> Sparrow.H2Worker.Pool.start_unregistered(:fcm, []) 53 | 54 | request = 55 | OuterRequest.new(headers, @body, "/HeaderToBodyEchoHandler", 2_000) 56 | 57 | {:ok, {answer_headers, answer_body}} = 58 | Sparrow.H2Worker.Pool.send_request(@pool_name, request) 59 | 60 | length_header = {"content-length", Integer.to_string(String.length(@body))} 61 | 62 | assert_response_header(answer_headers, {":status", "200"}) 63 | 64 | assert {Enum.into([length_header | headers], %{}), []} == 65 | Code.eval_string(answer_body) 66 | end 67 | 68 | test "cowboy echos headers in body, certificate based authentication", 69 | context do 70 | config = Setup.create_h2_worker_config(Setup.server_host(), context[:port]) 71 | 72 | headers = [ 73 | {"my_cool_header", "my_even_cooler_value"} | Setup.default_headers() 74 | ] 75 | 76 | Sparrow.H2Worker.Pool.Config.new(config, @pool_name) 77 | |> Sparrow.H2Worker.Pool.start_unregistered(:fcm, []) 78 | 79 | request = 80 | OuterRequest.new(headers, @body, "/HeaderToBodyEchoHandler", 2_000) 81 | 82 | {:ok, {answer_headers, answer_body}} = 83 | Sparrow.H2Worker.Pool.send_request(@pool_name, request) 84 | 85 | length_header = {"content-length", Integer.to_string(String.length(@body))} 86 | 87 | assert_response_header(answer_headers, {":status", "200"}) 88 | 89 | assert {Enum.into([length_header | headers], %{}), []} == 90 | Code.eval_string(answer_body) 91 | end 92 | 93 | test "cowboy echos headers in body, token based authentication", context do 94 | config = 95 | Setup.create_h2_worker_config( 96 | Setup.server_host(), 97 | context[:port], 98 | :token_based 99 | ) 100 | 101 | headers = [ 102 | {"my_cool_header", "my_even_cooler_value"} | Setup.default_headers() 103 | ] 104 | 105 | Sparrow.H2Worker.Pool.Config.new(config, @pool_name) 106 | |> Sparrow.H2Worker.Pool.start_unregistered(:fcm, []) 107 | 108 | request = 109 | OuterRequest.new(headers, @body, "/HeaderToBodyEchoHandler", 2_000) 110 | 111 | {:ok, {answer_headers, answer_body}} = 112 | Sparrow.H2Worker.Pool.send_request(@pool_name, request) 113 | 114 | length_header = {"content-length", Integer.to_string(String.length(@body))} 115 | token_auth_header = {"authorization", "bearer dummy_token"} 116 | 117 | assert_response_header(answer_headers, {":status", "200"}) 118 | 119 | assert {Enum.into([token_auth_header, length_header | headers], %{}), []} == 120 | Code.eval_string(answer_body) 121 | end 122 | 123 | test "cowboy replies Hello", context do 124 | config = Setup.create_h2_worker_config(Setup.server_host(), context[:port]) 125 | headers = Setup.default_headers() 126 | 127 | Sparrow.H2Worker.Pool.Config.new(config, @pool_name) 128 | |> Sparrow.H2Worker.Pool.start_unregistered(:fcm, []) 129 | 130 | request = OuterRequest.new(headers, @body, "/ConnTestHandler", 2_000) 131 | 132 | {:ok, {answer_headers, answer_body}} = 133 | Sparrow.H2Worker.Pool.send_request(@pool_name, request) 134 | 135 | assert_response_header(answer_headers, {":status", "200"}) 136 | 137 | assert_response_header( 138 | answer_headers, 139 | {"content-type", "text/plain; charset=utf-8"} 140 | ) 141 | 142 | assert_response_header(answer_headers, {"content-length", "5"}) 143 | assert answer_body == "Hello" 144 | end 145 | 146 | test "first open connection fails, second pases, certificate based authentication", 147 | context do 148 | with_mock H2Adapter, 149 | open: fn a, b, c -> 150 | case :erlang.put(:connection_count, 1) do 151 | :undefined -> {:error, :my_custom_reason} 152 | 1 -> :meck.passthrough([a, b, c]) 153 | end 154 | end do 155 | config = 156 | Setup.create_h2_worker_config(Setup.server_host(), context[:port]) 157 | 158 | {:ok, pid} = GenServer.start(Sparrow.H2Worker, config) 159 | assert is_pid(pid) 160 | end 161 | end 162 | 163 | test "first open connection fails, second pases, token based authentication", 164 | context do 165 | with_mock H2Adapter, 166 | open: fn a, b, c -> 167 | case :erlang.put(:connection_count, 1) do 168 | :undefined -> {:error, :my_custom_reason} 169 | 1 -> :meck.passthrough([a, b, c]) 170 | end 171 | end do 172 | config = 173 | Setup.create_h2_worker_config( 174 | Setup.server_host(), 175 | context[:port], 176 | :token_based 177 | ) 178 | 179 | {:ok, pid} = GenServer.start(Sparrow.H2Worker, config) 180 | assert is_pid(pid) 181 | end 182 | end 183 | 184 | defp assert_response_header(headers, expected_header) do 185 | assert Enum.any?(headers, &(&1 == expected_header)) 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /test/h2_integration/h2_adapter_instability_test.exs: -------------------------------------------------------------------------------- 1 | defmodule H2Integration.H2AdapterInstabilityTest do 2 | use ExUnit.Case 3 | use AssertEventually 4 | 5 | import Mock 6 | import Mox 7 | setup :set_mox_global 8 | setup :verify_on_exit! 9 | 10 | alias Helpers.SetupHelper, as: Setup 11 | alias Sparrow.H2ClientAdapter.Chatterbox, as: H2Adapter 12 | alias Sparrow.H2Worker.Request, as: OuterRequest 13 | alias Sparrow.H2Worker.State 14 | 15 | import Helpers.SetupHelper, only: [passthrough_h2: 1] 16 | setup :passthrough_h2 17 | 18 | setup do 19 | {:ok, cowboy_pid, cowboys_name} = 20 | [ 21 | {":_", 22 | [ 23 | {"/LostConnHandler", Helpers.CowboyHandlers.LostConnHandler, []} 24 | ]} 25 | ] 26 | |> :cowboy_router.compile() 27 | |> Setup.start_cowboy_tls(certificate_required: :no) 28 | 29 | on_exit(fn -> 30 | case Process.alive?(cowboy_pid) do 31 | true -> :cowboy.stop_listener(cowboys_name) 32 | _ -> :ok 33 | end 34 | end) 35 | 36 | {:ok, port: :ranch.get_port(cowboys_name)} 37 | end 38 | 39 | test "chatterbox process die with custom reason after sending request to cowboy", 40 | context do 41 | config = Setup.create_h2_worker_config(Setup.server_host(), context[:port]) 42 | 43 | headers = Setup.default_headers() 44 | body = "sound of silence, test body" 45 | 46 | {:ok, worker_pid} = GenServer.start_link(Sparrow.H2Worker, config) 47 | eventually(assert :sys.get_state(worker_pid).connection_ref != nil) 48 | conn_ref = :sys.get_state(worker_pid).connection_ref 49 | 50 | request = OuterRequest.new(headers, body, "/LostConnHandler", 3_000) 51 | 52 | spawn(fn -> 53 | :timer.sleep(500) 54 | Process.exit(conn_ref, :custom_reason) 55 | end) 56 | 57 | assert {:error, :connection_lost} == 58 | GenServer.call(worker_pid, {:send_request, request}) 59 | end 60 | 61 | test "reconnecting works after connection was lost", context do 62 | config = Setup.create_h2_worker_config(Setup.server_host(), context[:port]) 63 | 64 | headers = Setup.default_headers() 65 | body = "message, test body" 66 | 67 | {:ok, worker_pid} = GenServer.start_link(Sparrow.H2Worker, config) 68 | eventually(assert :sys.get_state(worker_pid).connection_ref != nil) 69 | conn_ref = :sys.get_state(worker_pid).connection_ref 70 | 71 | request = OuterRequest.new(headers, body, "/LostConnHandler", 3_000) 72 | 73 | spawn(fn -> 74 | :timer.sleep(500) 75 | Process.exit(conn_ref, :custom_reason) 76 | end) 77 | 78 | assert {:error, :connection_lost} == 79 | GenServer.call(worker_pid, {:send_request, request}) 80 | 81 | {:ok, {answer_headers, answer_body}} = 82 | GenServer.call(worker_pid, {:send_request, request}) 83 | 84 | assert_response_header(answer_headers, {":status", "200"}) 85 | 86 | assert_response_header( 87 | answer_headers, 88 | {"content-type", "text/plain; charset=utf-8"} 89 | ) 90 | 91 | assert_response_header(answer_headers, {"content-length", "5"}) 92 | assert answer_body == "Hello" 93 | end 94 | 95 | test "connecting fails works after connection was lost", context do 96 | with_mock H2Adapter, 97 | open: fn _, _, _ -> {:error, :my_custom_reason} end do 98 | config = 99 | Setup.create_h2_worker_config(Setup.server_host(), context[:port]) 100 | 101 | worker_pid = start_supervised!(Setup.h2_worker_spec(config)) 102 | 103 | %State{connection_ref: connection} = :sys.get_state(worker_pid) 104 | assert nil == connection 105 | end 106 | end 107 | 108 | defp assert_response_header(headers, expected_header) do 109 | assert Enum.any?(headers, &(&1 == expected_header)) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/h2_integration/token_based_authorisation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule H2Integration.TokenBasedAuthorisationTest do 2 | use ExUnit.Case 3 | import Mox 4 | setup :set_mox_global 5 | setup :verify_on_exit! 6 | 7 | alias H2Integration.Helpers.TokenHelper 8 | alias Helpers.SetupHelper, as: Setup 9 | alias Sparrow.H2Worker.Request, as: OuterRequest 10 | 11 | @path "/AuthenticateHandler" 12 | @pool_name :wname 13 | 14 | import Helpers.SetupHelper, only: [passthrough_h2: 1] 15 | setup :passthrough_h2 16 | 17 | setup do 18 | {:ok, cowboy_pid, cowboys_name} = 19 | [ 20 | {":_", 21 | [ 22 | {@path, Helpers.CowboyHandlers.AuthenticateHandler, []} 23 | ]} 24 | ] 25 | |> :cowboy_router.compile() 26 | |> Setup.start_cowboy_tls(certificate_required: :no) 27 | 28 | on_exit(fn -> 29 | case Process.alive?(cowboy_pid) do 30 | true -> :cowboy.stop_listener(cowboys_name) 31 | _ -> :ok 32 | end 33 | end) 34 | 35 | {:ok, port: :ranch.get_port(cowboys_name)} 36 | end 37 | 38 | test "token based authorisation with correct token succeed", context do 39 | config = Setup.create_h2_worker_config(Setup.server_host(), context[:port]) 40 | headers = Setup.default_headers() 41 | body = "message, test body" 42 | 43 | Sparrow.H2Worker.Pool.Config.new(config, @pool_name, 4, []) 44 | |> Sparrow.H2Worker.Pool.start_unregistered(:fcm, []) 45 | 46 | success_request = 47 | OuterRequest.new( 48 | [{"authorization", TokenHelper.get_correct_token()} | headers], 49 | body, 50 | @path, 51 | 3_000 52 | ) 53 | 54 | {:ok, {success_answer_headers, success_answer_body}} = 55 | Sparrow.H2Worker.Pool.send_request( 56 | @pool_name, 57 | success_request, 58 | true 59 | ) 60 | 61 | assert_response_header(success_answer_headers, {":status", "200"}) 62 | 63 | assert_response_header( 64 | success_answer_headers, 65 | {"content-type", "text/plain; charset=utf-8"} 66 | ) 67 | 68 | assert_response_header( 69 | success_answer_headers, 70 | {"content-length", 71 | "#{inspect(String.length(TokenHelper.get_correct_token_response_body()))}"} 72 | ) 73 | 74 | assert success_answer_body == TokenHelper.get_correct_token_response_body() 75 | end 76 | 77 | test "token based authorisation with incorrect token fails", context do 78 | config = Setup.create_h2_worker_config(Setup.server_host(), context[:port]) 79 | 80 | headers = Setup.default_headers() 81 | body = "message, test body" 82 | 83 | Sparrow.H2Worker.Pool.Config.new(config, @pool_name, 4, []) 84 | |> Sparrow.H2Worker.Pool.start_unregistered(:fcm, []) 85 | 86 | fail_request = 87 | OuterRequest.new( 88 | [{"authorization", TokenHelper.get_incorrect_token()} | headers], 89 | body, 90 | @path, 91 | 3_000 92 | ) 93 | 94 | {:ok, {fail_answer_headers, fail_answer_body}} = 95 | Sparrow.H2Worker.Pool.send_request(@pool_name, fail_request) 96 | 97 | assert_response_header(fail_answer_headers, {":status", "401"}) 98 | 99 | assert_response_header( 100 | fail_answer_headers, 101 | {"content-type", "text/plain; charset=utf-8"} 102 | ) 103 | 104 | assert_response_header( 105 | fail_answer_headers, 106 | {"content-length", 107 | "#{inspect(String.length(TokenHelper.get_incorrect_token_response_body()))}"} 108 | ) 109 | 110 | assert fail_answer_body == TokenHelper.get_incorrect_token_response_body() 111 | end 112 | 113 | defp assert_response_header(headers, expected_header) do 114 | assert Enum.any?(headers, &(&1 == expected_header)) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/h2_worker/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule H2Worker.ConfigTest do 2 | use ExUnit.Case 3 | use Quixir 4 | 5 | @path_to_cert "test/priv/certs/Certificates1.pem" 6 | @path_to_key "test/priv/certs/key.pem" 7 | 8 | @repeats 10 9 | 10 | test "authentication type recognised correctly, for certificate" do 11 | ptest [ 12 | domain: string(min: 3, max: 15, chars: :ascii), 13 | port: string(min: 3, max: 15, chars: :ascii) 14 | ], 15 | repeat_for: @repeats do 16 | auth = 17 | Sparrow.H2Worker.Authentication.CertificateBased.new( 18 | @path_to_cert, 19 | @path_to_key 20 | ) 21 | 22 | config = 23 | Sparrow.H2Worker.Config.new(%{ 24 | domain: domain, 25 | port: port, 26 | authentication: auth 27 | }) 28 | 29 | assert :certificate_based == 30 | Sparrow.H2Worker.Config.get_authentication_type(config) 31 | end 32 | end 33 | 34 | test "authentication type recognised correctly for token" do 35 | ptest [ 36 | domain: string(min: 3, max: 15, chars: :ascii), 37 | port: string(min: 3, max: 15, chars: :ascii) 38 | ], 39 | repeat_for: @repeats do 40 | auth = 41 | Sparrow.H2Worker.Authentication.TokenBased.new(fn -> "dummyToken" end) 42 | 43 | config = 44 | Sparrow.H2Worker.Config.new(%{ 45 | domain: domain, 46 | port: port, 47 | authentication: auth 48 | }) 49 | 50 | assert :token_based == 51 | Sparrow.H2Worker.Config.get_authentication_type(config) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/h2_worker/connection_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2Worker.ConnectionTest do 2 | alias Helpers.SetupHelper, as: Tools 3 | use ExUnit.Case 4 | use Quixir 5 | 6 | import Mox 7 | setup :set_mox_global 8 | setup :verify_on_exit! 9 | 10 | alias Sparrow.H2Worker.Config 11 | alias Sparrow.H2Worker.Request, as: OuterRequest 12 | 13 | @repeats 2 14 | 15 | setup do 16 | auth = 17 | Sparrow.H2Worker.Authentication.CertificateBased.new( 18 | "path/to/exampleName.pem", 19 | "path/to/exampleKey.pem" 20 | ) 21 | 22 | real_auth = 23 | Sparrow.H2Worker.Authentication.CertificateBased.new( 24 | "test/priv/certs/Certificates1.pem", 25 | "test/priv/certs/key.pem" 26 | ) 27 | 28 | {:ok, connection_ref: pid(), auth: auth, real_auth: real_auth} 29 | end 30 | 31 | test "connection attempts with backoffs at server's startup", context do 32 | ptest [ 33 | domain: string(min: 3, max: 10, chars: ?a..?z), 34 | port: int(min: 0, max: 65_535), 35 | reason: atom(min: 2, max: 5), 36 | tls_options: list(of: atom(), min: 0, max: 3) 37 | ], 38 | repeat_for: @repeats do 39 | me = self() 40 | 41 | Sparrow.H2ClientAdapter.Mock 42 | |> expect(:open, 1, fn _, _, _ -> 43 | send(me, {:first_connection_failure, :os.system_time(:millisecond)}) 44 | {:error, reason} 45 | end) 46 | |> expect(:open, 4, fn _, _, _ -> {:error, reason} end) 47 | |> expect(:open, 1, fn _, _, _ -> 48 | send(me, {:first_connection_success, :os.system_time(:millisecond)}) 49 | {:ok, context[:connection_ref]} 50 | end) 51 | |> stub(:ping, fn _ -> :ok end) 52 | |> stub(:post, fn _, _, _, _, _ -> {:error, :something} end) 53 | |> stub(:get_response, fn _, _ -> {:error, :something} end) 54 | |> stub(:close, fn _ -> :ok end) 55 | 56 | config = 57 | Config.new(%{ 58 | domain: domain, 59 | port: port, 60 | authentication: context[:auth], 61 | tls_options: tls_options, 62 | backoff_base: 2, 63 | backoff_initial_delay: 100, 64 | backoff_max_delay: 400 65 | }) 66 | 67 | {:ok, _pid} = start_supervised(Tools.h2_worker_spec(config)) 68 | 69 | assert_receive {:first_connection_failure, _f}, 200 70 | assert_receive {:first_connection_success, _s}, 2_000 71 | 72 | # FIXME: global mock is making this test flaky, 73 | # i.e. if some other process calls mock.open/3 74 | # then the whole backoff time is shorter than expected 75 | # assert_in_delta f, s, 1900 76 | # refute_in_delta f, s, 1800 77 | end 78 | end 79 | 80 | test "reconnection attempts with backoffs after the connection is closed", 81 | context do 82 | ptest [ 83 | domain: string(min: 3, max: 10, chars: ?a..?z), 84 | port: int(min: 0, max: 65_535), 85 | reason: atom(min: 2, max: 5), 86 | tls_options: list(of: atom(), min: 0, max: 3) 87 | ], 88 | repeat_for: @repeats do 89 | conn_pid = pid() 90 | me = self() 91 | 92 | Sparrow.H2ClientAdapter.Mock 93 | |> expect(:open, 1, fn _, _, _ -> 94 | send(me, {:connection_success, :os.system_time(:millisecond)}) 95 | {:ok, conn_pid} 96 | end) 97 | |> expect(:open, 1, fn _, _, _ -> 98 | send(me, {:reconnection_failure, :os.system_time(:millisecond)}) 99 | {:error, reason} 100 | end) 101 | |> expect(:open, 4, fn _, _, _ -> {:error, reason} end) 102 | |> expect(:open, 1, fn _, _, _ -> 103 | send(me, {:reconnection_success, :os.system_time(:millisecond)}) 104 | {:ok, context[:connection_ref]} 105 | end) 106 | |> stub(:ping, fn ref -> 107 | send(self(), {:PONG, ref}) 108 | :ok 109 | end) 110 | |> stub(:post, fn _, _, _, _, _ -> {:error, :something} end) 111 | |> stub(:get_response, fn _, _ -> {:error, :something} end) 112 | |> stub(:close, fn _ -> :ok end) 113 | 114 | config = 115 | Config.new(%{ 116 | domain: domain, 117 | port: port, 118 | authentication: context[:auth], 119 | tls_options: tls_options, 120 | backoff_base: 2, 121 | backoff_initial_delay: 100, 122 | backoff_max_delay: 400 123 | }) 124 | 125 | {:ok, _pid} = start_supervised(Tools.h2_worker_spec(config)) 126 | assert_receive {:connection_success, _s}, 200 127 | send(conn_pid, :exit) 128 | 129 | assert_receive {:reconnection_failure, _f}, 200 130 | assert_receive {:reconnection_success, _s}, 2_000 131 | 132 | # FIXME: global mock is making this test flaky, 133 | # i.e. if some other process calls mock.open/3 134 | # then the whole backoff time is shorter than expected 135 | # assert_in_delta f, s, 1900 136 | # refute_in_delta f, s, 1800 137 | end 138 | end 139 | 140 | test "reconnection attempts with backoffs after the request is sent", 141 | context do 142 | ptest [ 143 | domain: string(min: 3, max: 10, chars: ?a..?z), 144 | port: int(min: 0, max: 65_535), 145 | reason: atom(min: 2, max: 5), 146 | tls_options: list(of: atom(), min: 0, max: 3), 147 | headersA: list(of: string(), min: 2, max: 2, chars: :ascii), 148 | headersB: list(of: string(), min: 2, max: 2, chars: :ascii), 149 | body: string(min: 3, max: 7, chars: :ascii), 150 | path: string(min: 3, max: 7, chars: :ascii) 151 | ], 152 | repeat_for: @repeats do 153 | me = self() 154 | request_timeout = 300 155 | headers = Enum.zip([headersA, headersB]) 156 | 157 | Sparrow.H2ClientAdapter.Mock 158 | |> expect(:open, 1, fn _, _, _ -> 159 | send(me, :connection_failure) 160 | {:error, reason} 161 | end) 162 | |> expect(:open, 3, fn _, _, _ -> {:error, reason} end) 163 | |> expect(:open, 1, fn _, _, _ -> 164 | send(me, :connection_failure) 165 | {:error, reason} 166 | end) 167 | |> expect(:open, 1, fn _, _, _ -> 168 | send(me, :connection_success) 169 | {:ok, context[:connection_ref]} 170 | end) 171 | |> stub(:ping, fn ref -> 172 | send(self(), {:PONG, ref}) 173 | :ok 174 | end) 175 | |> stub(:post, fn _, _, _, _, _ -> 176 | {:error, {:unable_to_connect, :some_reason}} 177 | end) 178 | |> stub(:get_response, fn _, _ -> {:error, :something} end) 179 | |> stub(:close, fn _ -> :ok end) 180 | 181 | config = 182 | Config.new(%{ 183 | domain: domain, 184 | port: port, 185 | authentication: context[:auth], 186 | tls_options: tls_options, 187 | backoff_base: 2, 188 | backoff_initial_delay: 2_000, 189 | backoff_max_delay: 2_000 190 | }) 191 | 192 | request = OuterRequest.new(headers, body, path, request_timeout) 193 | 194 | {:ok, pid} = start_supervised(Tools.h2_worker_spec(config)) 195 | 196 | assert_receive :connection_failure, 100 197 | 198 | assert {:error, {:unable_to_connect, _}} = 199 | GenServer.call(pid, {:send_request, request}) 200 | 201 | assert_receive :connection_failure, 2_000 202 | assert_receive :connection_success, 5_000 203 | end 204 | end 205 | 206 | defp pid do 207 | spawn(fn -> do_nothing() end) 208 | end 209 | 210 | defp do_nothing do 211 | receive do 212 | :exit -> exit(:reason) 213 | end 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /test/h2_worker/pool/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2Worker.Pool.ConfigTest do 2 | use ExUnit.Case 3 | 4 | test "create new config with known name" do 5 | auth = 6 | Sparrow.H2Worker.Authentication.TokenBased.new(fn -> 7 | {"authorization", "bearer dummy token"} 8 | end) 9 | 10 | workers_config = 11 | Sparrow.H2Worker.Config.new(%{ 12 | domain: "fake.url.com", 13 | port: 1234, 14 | authentication: auth 15 | }) 16 | 17 | pool_name = :my_pool_name 18 | worker_num = 10 19 | raw_opts = [] 20 | 21 | config = 22 | Sparrow.H2Worker.Pool.Config.new( 23 | workers_config, 24 | pool_name, 25 | worker_num, 26 | raw_opts 27 | ) 28 | 29 | assert pool_name == config.pool_name 30 | assert worker_num == config.worker_num 31 | assert workers_config == config.workers_config 32 | assert raw_opts == config.raw_opts 33 | end 34 | 35 | test "create new config with nil name" do 36 | auth = 37 | Sparrow.H2Worker.Authentication.TokenBased.new(fn -> 38 | {"authorization", "bearer dummy token"} 39 | end) 40 | 41 | workers_config = 42 | Sparrow.H2Worker.Config.new(%{ 43 | domain: "fake.url.com", 44 | port: 1234, 45 | authentication: auth 46 | }) 47 | 48 | config = Sparrow.H2Worker.Pool.Config.new(workers_config, nil) 49 | assert nil != config.pool_name 50 | assert is_atom(config.pool_name) 51 | assert 3 == config.worker_num 52 | assert workers_config == config.workers_config 53 | assert [] == config.raw_opts 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/h2_worker/pool_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sparrow.H2Worker.PoolTest do 2 | use ExUnit.Case 3 | 4 | import Mox 5 | setup :set_mox_global 6 | setup :verify_on_exit! 7 | 8 | alias Helpers.SetupHelper, as: Setup 9 | alias Sparrow.H2Worker.Request, as: OuterRequest 10 | 11 | @pool_name :pool_name 12 | @body "test body" 13 | 14 | import Helpers.SetupHelper, only: [passthrough_h2: 1] 15 | setup :passthrough_h2 16 | 17 | setup do 18 | {:ok, cowboy_pid, cowboys_name} = 19 | [ 20 | {":_", 21 | [ 22 | {"/ConnTestHandler", Helpers.CowboyHandlers.ConnectionHandler, []}, 23 | {"/HeaderToBodyEchoHandler", 24 | Helpers.CowboyHandlers.HeaderToBodyEchoHandler, []}, 25 | {"/TimeoutHandler", Helpers.CowboyHandlers.TimeoutHandler, []} 26 | ]} 27 | ] 28 | |> :cowboy_router.compile() 29 | |> Setup.start_cowboy_tls(certificate_required: :no) 30 | 31 | port = :ranch.get_port(cowboys_name) 32 | config = Setup.create_h2_worker_config(Setup.server_host(), port) 33 | :wpool.start() 34 | 35 | :wpool.start_pool( 36 | @pool_name, 37 | [ 38 | {:workers, 4}, 39 | {:worker, {Sparrow.H2Worker, config}} 40 | ] 41 | ) 42 | 43 | on_exit(fn -> 44 | case Process.alive?(cowboy_pid) do 45 | true -> :cowboy.stop_listener(cowboys_name) 46 | _ -> :ok 47 | end 48 | end) 49 | 50 | {:ok, port: port} 51 | end 52 | 53 | test "cowboy echos headers in body" do 54 | headers = [ 55 | {"my_cool_header", "my_even_cooler_value"} | Setup.default_headers() 56 | ] 57 | 58 | request = 59 | OuterRequest.new(headers, @body, "/HeaderToBodyEchoHandler", 2_000) 60 | 61 | {:ok, {answer_headers, answer_body}} = 62 | :wpool.call(@pool_name, {:send_request, request}) 63 | 64 | length_header = {"content-length", Integer.to_string(String.length(@body))} 65 | 66 | assert_response_header(answer_headers, {":status", "200"}) 67 | 68 | assert {Enum.into([length_header | headers], %{}), []} == 69 | Code.eval_string(answer_body) 70 | end 71 | 72 | test "cowboy echos headers in body, certificate based authentication" do 73 | headers = [ 74 | {"my_cool_header", "my_even_cooler_value"} | Setup.default_headers() 75 | ] 76 | 77 | request = 78 | OuterRequest.new(headers, @body, "/HeaderToBodyEchoHandler", 2_000) 79 | 80 | {:ok, {answer_headers, answer_body}} = 81 | :wpool.call(@pool_name, {:send_request, request}) 82 | 83 | length_header = {"content-length", Integer.to_string(String.length(@body))} 84 | 85 | assert_response_header(answer_headers, {":status", "200"}) 86 | 87 | assert {Enum.into([length_header | headers], %{}), []} == 88 | Code.eval_string(answer_body) 89 | end 90 | 91 | test "cowboy replies Hello" do 92 | headers = Setup.default_headers() 93 | 94 | request = OuterRequest.new(headers, @body, "/ConnTestHandler", 2_000) 95 | 96 | {:ok, {answer_headers, answer_body}} = 97 | :wpool.call(@pool_name, {:send_request, request}) 98 | 99 | assert_response_header(answer_headers, {":status", "200"}) 100 | 101 | assert_response_header( 102 | answer_headers, 103 | {"content-type", "text/plain; charset=utf-8"} 104 | ) 105 | 106 | assert_response_header(answer_headers, {"content-length", "5"}) 107 | assert answer_body == "Hello" 108 | end 109 | 110 | test "cowboy replies Hello async" do 111 | headers = Setup.default_headers() 112 | 113 | request = OuterRequest.new(headers, @body, "/ConnTestHandler", 2_000) 114 | 115 | assert :ok == Sparrow.H2Worker.Pool.send_request(@pool_name, request, false) 116 | end 117 | 118 | @messages_for_pool 1_000 119 | @moduletag :capture_log 120 | test "cowboy replies Hello n times" do 121 | headers = Setup.default_headers() 122 | 123 | request = OuterRequest.new(headers, @body, "/ConnTestHandler", 2_000) 124 | 125 | sending_with_response = fn -> 126 | for _ <- 1..@messages_for_pool do 127 | async_wpool_call(@pool_name, {:send_request, request}) 128 | end 129 | 130 | for _ <- 1..@messages_for_pool do 131 | receive do 132 | {:ok, {answer_headers, answer_body}} -> 133 | increase_inner_counter() 134 | assert_response_header(answer_headers, {":status", "200"}) 135 | assert answer_body == "Hello" 136 | after 137 | 1000 -> :ok 138 | end 139 | end 140 | end 141 | 142 | {_time, _} = :timer.tc(sending_with_response) 143 | assert get_inner_counter() == @messages_for_pool 144 | end 145 | 146 | defp assert_response_header(headers, expected_header) do 147 | assert Enum.any?(headers, &(&1 == expected_header)) 148 | end 149 | 150 | defp async_wpool_call(pool_name, request) do 151 | pid = self() 152 | 153 | spawn(fn -> 154 | send(pid, :wpool.call(pool_name, request)) 155 | end) 156 | end 157 | 158 | defp increase_inner_counter do 159 | new = 160 | case :erlang.get(:results_counter) do 161 | :undefined -> 162 | 1 163 | 164 | x -> 165 | x + 1 166 | end 167 | 168 | :erlang.put(:results_counter, new) 169 | end 170 | 171 | defp get_inner_counter do 172 | :erlang.get(:results_counter) 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /test/h2_worker/request_set_test.exs: -------------------------------------------------------------------------------- 1 | defmodule H2Worker.RequestSetTest do 2 | use ExUnit.Case 3 | use Quixir 4 | 5 | alias Sparrow.H2Worker.RequestSet 6 | alias Sparrow.H2Worker.RequestState, as: InnerRequest 7 | doctest Sparrow.H2Worker.RequestSet 8 | 9 | @repeats 10 10 | 11 | test "collection is correctly initalizing" do 12 | requests = RequestSet.new() 13 | assert Enum.empty?(requests) 14 | end 15 | 16 | test "collection add, remove, get_addressee behavious correctly" do 17 | ptest [ 18 | headersA1: list(of: string(), min: 2, max: 2, chars: :ascii), 19 | headersB1: list(of: string(), min: 2, max: 2, chars: :ascii), 20 | body1: string(min: 3, max: 15, chars: :ascii), 21 | path1: string(min: 3, max: 15, chars: :ascii), 22 | from_tag1: atom(min: 5, max: 20), 23 | headersA2: list(of: string(), min: 3, max: 3, chars: :ascii), 24 | headersB2: list(of: string(), min: 3, max: 3, chars: :ascii), 25 | body2: string(min: 3, max: 15, chars: :ascii), 26 | path2: string(min: 3, max: 15, chars: :ascii), 27 | from_tag2: atom(min: 5, max: 20) 28 | ], 29 | repeat_for: @repeats do 30 | stream_id1 = 1111 31 | stream_id2 = 2222 32 | stream_id3_not_exisiting = 3333 33 | 34 | headers1 = Enum.zip([headersA1, headersB1]) 35 | from_pid1 = pid("0.12.13") 36 | headers2 = Enum.zip([headersA2, headersB2]) 37 | from_pid2 = pid("0.13.12") 38 | 39 | outer_request1 = Sparrow.H2Worker.Request.new(headers1, body1, path1) 40 | outer_request2 = Sparrow.H2Worker.Request.new(headers2, body2, path2) 41 | 42 | request1 = 43 | InnerRequest.new(outer_request1, {from_pid1, from_tag1}, make_ref()) 44 | 45 | request2 = 46 | InnerRequest.new(outer_request2, {from_pid2, from_tag2}, make_ref()) 47 | 48 | requests_collection = RequestSet.new() 49 | assert Enum.empty?(requests_collection) 50 | 51 | updated_requests_collection = 52 | RequestSet.add(requests_collection, stream_id1, request1) 53 | 54 | assert 1 == Enum.count(updated_requests_collection) 55 | 56 | reupdated_requests_collection = 57 | RequestSet.add(updated_requests_collection, stream_id2, request2) 58 | 59 | assert 2 == Enum.count(reupdated_requests_collection) 60 | 61 | rereupdated_requests_collection = 62 | RequestSet.add(updated_requests_collection, stream_id2, request2) 63 | 64 | assert 2 == Enum.count(rereupdated_requests_collection) 65 | 66 | assert {:ok, request1} == 67 | RequestSet.get_request(reupdated_requests_collection, stream_id1) 68 | 69 | assert {:error, :not_found} == 70 | RequestSet.get_request( 71 | reupdated_requests_collection, 72 | stream_id3_not_exisiting 73 | ) 74 | 75 | requests_collection_with_request2_removed = 76 | RequestSet.remove(reupdated_requests_collection, stream_id1) 77 | 78 | assert 1 == Enum.count(requests_collection_with_request2_removed) 79 | assert [stream_id2] == Map.keys(requests_collection_with_request2_removed) 80 | end 81 | end 82 | 83 | defp pid(string) when is_binary(string) do 84 | :erlang.list_to_pid(~c"<#{string}>") 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/h2_worker/request_test.exs: -------------------------------------------------------------------------------- 1 | defmodule H2Worker.RequestTest do 2 | use ExUnit.Case 3 | use Quixir 4 | 5 | @repeats 10 6 | 7 | test "creating input request works" do 8 | ptest [ 9 | headersA: list(of: string(), min: 2, max: 2, chars: :ascii), 10 | headersB: list(of: string(), min: 2, max: 2, chars: :ascii), 11 | body: string(min: 3, max: 15, chars: :ascii), 12 | path: string(min: 3, max: 15, chars: :ascii) 13 | ], 14 | repeat_for: @repeats do 15 | headers = Enum.zip([headersA, headersB]) 16 | 17 | %name{} = Sparrow.H2Worker.Request.new(headers, body, path) 18 | assert Sparrow.H2Worker.Request == name 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/helpers/cerificate_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.CerificateHelper do 2 | @moduledoc false 3 | require Record 4 | 5 | Record.defrecord( 6 | :otp_cert, 7 | Record.extract( 8 | :OTPCertificate, 9 | from_lib: "public_key/include/public_key.hrl" 10 | ) 11 | ) 12 | 13 | Record.defrecord( 14 | :tbs_cert, 15 | Record.extract( 16 | :OTPTBSCertificate, 17 | from_lib: "public_key/include/public_key.hrl" 18 | ) 19 | ) 20 | 21 | Record.defrecord( 22 | :cert_attr, 23 | Record.extract( 24 | :AttributeTypeAndValue, 25 | from_lib: "public_key/include/public_key.hrl" 26 | ) 27 | ) 28 | 29 | def get_subject_name_form_encoded_cert(cert) do 30 | {:OTPCertificate, cert, _, _} = :public_key.pkix_decode_cert(cert, :otp) 31 | 32 | cert 33 | |> tbs_cert(:subject) 34 | |> parse_subject_name() 35 | end 36 | 37 | def get_subject_name_form_not_encoded_cert(pem_bin) do 38 | [{:Certificate, binary_cert, :not_encrypted}] = 39 | :public_key.pem_decode(pem_bin) 40 | 41 | {:OTPCertificate, cert, _, _} = 42 | :public_key.pkix_decode_cert(binary_cert, :otp) 43 | 44 | cert 45 | |> tbs_cert(:subject) 46 | |> parse_subject_name() 47 | end 48 | 49 | defp parse_subject_name({:rdnSequence, rdn_sequence}) do 50 | rdn_sequence 51 | |> List.flatten() 52 | # Get value for each RDN 53 | |> Enum.map(&cert_attr(&1, :value)) 54 | |> Enum.map(&normalize_rdn_string/1) 55 | |> List.insert_at(0, "") 56 | |> Enum.join("/") 57 | end 58 | 59 | defp normalize_rdn_string({_string_type, name}), 60 | do: normalize_rdn_string(name) 61 | 62 | defp normalize_rdn_string(name), do: ~s"#{name}" 63 | end 64 | -------------------------------------------------------------------------------- /test/helpers/cowboy_handlers/authenticate_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.CowboyHandlers.AuthenticateHandler do 2 | @moduledoc false 3 | alias H2Integration.Helpers.TokenHelper 4 | 5 | def init(req, opts) do 6 | {code, answer} = 7 | "authorization" 8 | |> :cowboy_req.header(req) 9 | |> verify_token_and_get_response() 10 | 11 | reply = 12 | :cowboy_req.reply( 13 | code, 14 | %{"content-type" => "text/plain; charset=utf-8"}, 15 | answer, 16 | req 17 | ) 18 | 19 | {:ok, reply, opts} 20 | end 21 | 22 | defp verify_token_and_get_response(token) do 23 | correct_token = TokenHelper.get_correct_token() 24 | 25 | case token do 26 | ^correct_token -> {200, TokenHelper.get_correct_token_response_body()} 27 | _ -> {401, TokenHelper.get_incorrect_token_response_body()} 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/helpers/cowboy_handlers/connection_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.CowboyHandlers.ConnectionHandler do 2 | @moduledoc false 3 | def init(req, opts) do 4 | reply = 5 | :cowboy_req.reply( 6 | 200, 7 | %{"content-type" => "text/plain; charset=utf-8"}, 8 | "Hello", 9 | req 10 | ) 11 | 12 | {:ok, reply, opts} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/helpers/cowboy_handlers/echo_body_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.CowboyHandlers.EchoBodyHandler do 2 | @moduledoc false 3 | def init(req, opts) do 4 | {_, body, _} = :cowboy_req.read_body(req) 5 | 6 | reply = 7 | :cowboy_req.reply( 8 | 200, 9 | %{"content-type" => "application/json; charset=UTF-8"}, 10 | body, 11 | req 12 | ) 13 | 14 | {:ok, reply, opts} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/helpers/cowboy_handlers/echo_client_cerificate_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.CowboyHandlers.EchoClientCerificateHandler do 2 | @moduledoc false 3 | def init(req, opts) do 4 | subject = 5 | req 6 | |> :cowboy_req.cert() 7 | |> Helpers.CerificateHelper.get_subject_name_form_encoded_cert() 8 | 9 | reply = 10 | :cowboy_req.reply( 11 | 200, 12 | %{ 13 | "content-type" => "text/plain; charset=utf-8" 14 | }, 15 | subject, 16 | req 17 | ) 18 | 19 | {:ok, reply, opts} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/helpers/cowboy_handlers/error_response_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.CowboyHandlers.ErrorResponseHandler do 2 | @moduledoc false 3 | def init(req, opts) do 4 | {:ok, reason} = %{"reason" => "My error reason"} |> Jason.encode() 5 | 6 | reply = 7 | :cowboy_req.reply( 8 | 321, 9 | %{"content-type" => "application/json; charset=UTF-8"}, 10 | "#{reason}", 11 | req 12 | ) 13 | 14 | {:ok, reply, opts} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/helpers/cowboy_handlers/header_to_body_echo_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.CowboyHandlers.HeaderToBodyEchoHandler do 2 | @moduledoc false 3 | def init(req0, opts) do 4 | method = :cowboy_req.method(req0) 5 | req = maybe_echo(method, req0) 6 | {:ok, req, opts} 7 | end 8 | 9 | defp maybe_echo("POST", req) do 10 | allheaders = :cowboy_req.headers(req) 11 | echo("#{inspect(allheaders)}", req) 12 | end 13 | 14 | defp echo(echo, req) do 15 | :cowboy_req.reply( 16 | 200, 17 | %{ 18 | "content-type" => "text/plain; charset=utf-8" 19 | }, 20 | echo, 21 | req 22 | ) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/helpers/cowboy_handlers/lost_conn_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.CowboyHandlers.LostConnHandler do 2 | @moduledoc false 3 | def init(req, opts) do 4 | :timer.sleep(2_000) 5 | 6 | reply = 7 | :cowboy_req.reply( 8 | 200, 9 | %{"content-type" => "text/plain; charset=utf-8"}, 10 | "Hello", 11 | req 12 | ) 13 | 14 | {:ok, reply, opts} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/helpers/cowboy_handlers/ok_fcm_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.CowboyHandlers.OkFCMHandler do 2 | @moduledoc false 3 | def init(req, opts) do 4 | reply = 5 | :cowboy_req.reply( 6 | 200, 7 | %{"content-type" => "application/json; charset=UTF-8"}, 8 | "{ 9 | \"name\": \"projects/myproject-b5ae1/messages/0:1500415314455276%31bd1c9631bd1c96\" 10 | }", 11 | req 12 | ) 13 | 14 | {:ok, reply, opts} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/helpers/cowboy_handlers/ok_response_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.CowboyHandlers.OkResponseHandler do 2 | @moduledoc false 3 | def init(req, opts) do 4 | reply = 5 | :cowboy_req.reply( 6 | 200, 7 | %{"content-type" => "application/json; charset=UTF-8"}, 8 | "", 9 | req 10 | ) 11 | 12 | {:ok, reply, opts} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/helpers/cowboy_handlers/reject_certificate_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.CowboyHandlers.RejectCertificateHandler do 2 | @moduledoc false 3 | def init(req, opts) do 4 | reply = 5 | :cowboy_req.reply( 6 | 495, 7 | %{"content-type" => "text/plain; charset=utf-8"}, 8 | "Hello", 9 | req 10 | ) 11 | 12 | {:ok, reply, opts} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/helpers/cowboy_handlers/timeout_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.CowboyHandlers.TimeoutHandler do 2 | @moduledoc false 3 | def init(req, opts) do 4 | :timer.sleep(2_000) 5 | 6 | reply = 7 | :cowboy_req.reply( 8 | 200, 9 | %{"content-type" => "text/plain; charset=utf-8"}, 10 | "Hello", 11 | req 12 | ) 13 | 14 | {:ok, reply, opts} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/helpers/mocks.ex: -------------------------------------------------------------------------------- 1 | Mox.defmock(Sparrow.H2ClientAdapter.Mock, for: Sparrow.H2ClientAdapter) 2 | -------------------------------------------------------------------------------- /test/helpers/setup_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers.SetupHelper do 2 | @moduledoc false 3 | 4 | import Mox 5 | 6 | alias Sparrow.H2Worker.Config 7 | 8 | @path_to_cert "priv/ssl/client_cert.pem" 9 | @path_to_key "priv/ssl/client_key.pem" 10 | 11 | def passthrough_h2(state) do 12 | Sparrow.H2ClientAdapter.Mock 13 | |> stub_with(Sparrow.H2ClientAdapter.Chatterbox) 14 | 15 | state 16 | end 17 | 18 | def h2_worker_spec(config) do 19 | id = :crypto.strong_rand_bytes(8) |> Base.encode64() 20 | Process.put(:id, id) 21 | 22 | Supervisor.child_spec({Sparrow.H2Worker, config}, id: id) 23 | end 24 | 25 | def child_spec(opts) do 26 | args = opts[:args] 27 | name = opts[:name] 28 | 29 | id = :rand.uniform(100_000) 30 | 31 | %{ 32 | :id => id, 33 | :start => {Sparrow.H2Worker, :start_link, [name, args]} 34 | } 35 | end 36 | 37 | def cowboys_name do 38 | :look 39 | end 40 | 41 | def create_h2_worker_config( 42 | address \\ server_host(), 43 | port \\ 8080, 44 | authentication \\ :certificate_based 45 | ) do 46 | auth = 47 | case authentication do 48 | :token_based -> 49 | Sparrow.H2Worker.Authentication.TokenBased.new(fn -> 50 | {"authorization", "bearer dummy_token"} 51 | end) 52 | 53 | :certificate_based -> 54 | Sparrow.H2Worker.Authentication.CertificateBased.new( 55 | @path_to_cert, 56 | @path_to_key 57 | ) 58 | end 59 | 60 | Config.new(%{ 61 | domain: address, 62 | port: port, 63 | authentication: auth, 64 | backoff_base: 2, 65 | backoff_initial_delay: 100, 66 | backoff_max_delay: 400, 67 | reconnect_attempts: 0, 68 | tls_options: [verify: :verify_none] 69 | }) 70 | end 71 | 72 | defp certificate_settings_list do 73 | [ 74 | {:cacertfile, "priv/ssl/fake_cert.pem"}, 75 | {:certfile, "priv/ssl/fake_cert.pem"}, 76 | {:keyfile, "priv/ssl/fake_key.pem"} 77 | ] 78 | end 79 | 80 | defp settings_list(:positive_cerificate_verification, port) do 81 | [ 82 | {:port, port}, 83 | {:verify, :verify_peer}, 84 | {:verify_fun, {fn _, _, _ -> {:valid, :ok} end, :ok}} 85 | | certificate_settings_list() 86 | ] 87 | end 88 | 89 | defp settings_list(:negative_cerificate_verification, port) do 90 | [ 91 | {:port, port}, 92 | {:verify, :verify_peer}, 93 | {:verify_fun, 94 | {fn _, _, _ -> {:fail, :negative_cerificate_verification} end, :ok}} 95 | | certificate_settings_list() 96 | ] 97 | end 98 | 99 | defp settings_list(:no, port) do 100 | [ 101 | {:port, port} 102 | | certificate_settings_list() 103 | ] 104 | end 105 | 106 | def start_cowboy_tls(dispatch_config, opts) do 107 | cert_required = Keyword.get(opts, :certificate_required, :no) 108 | port = Keyword.get(opts, :port, 0) 109 | name = Keyword.get(opts, :name, :look) 110 | settings_list = settings_list(cert_required, port) 111 | 112 | {:ok, pid} = 113 | :cowboy.start_tls( 114 | name, 115 | settings_list, 116 | %{:env => %{:dispatch => dispatch_config}} 117 | ) 118 | 119 | {:ok, pid, name} 120 | end 121 | 122 | def server_host do 123 | "localhost" 124 | end 125 | 126 | def default_headers do 127 | [ 128 | {"accept", "*/*"}, 129 | {"accept-encoding", "gzip, deflate"}, 130 | {"user-agent", "chatterbox-client/0.0.1"} 131 | ] 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/helpers/token_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule H2Integration.Helpers.TokenHelper do 2 | @moduledoc false 3 | def get_correct_token do 4 | "Authentication_passed_token" 5 | end 6 | 7 | def get_incorrect_token do 8 | "Authentication_failed_token" 9 | end 10 | 11 | def get_correct_token_response_body do 12 | "Authorised" 13 | end 14 | 15 | def get_incorrect_token_response_body do 16 | "Not Authorised" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(capture_log: true) 2 | 3 | defmodule TestHelper do 4 | def restore_app_env() do 5 | Application.stop(:sparrow) 6 | Application.unload(:sparrow) 7 | Application.load(:sparrow) 8 | {:ok, _} = Application.ensure_all_started(:sparrow) 9 | :ok 10 | end 11 | end 12 | 13 | defmodule HelperMacros do 14 | defmacro __using__(_opts) do 15 | quote do 16 | @eventually_timeout 5000 17 | import unquote(__MODULE__) 18 | end 19 | end 20 | 21 | defmacro eventually(truly) do 22 | quote do 23 | HelperMacros.wait_for(fn -> unquote(truly) end, @eventually_timeout) 24 | end 25 | end 26 | 27 | def wait_for(fun, timeout) when timeout > 0 do 28 | timestep = 100 29 | 30 | case fun.() do 31 | true -> 32 | true 33 | 34 | false -> 35 | Process.sleep(timestep) 36 | wait_for(fun, timeout - timestep) 37 | end 38 | end 39 | 40 | def wait_for(fun, _timeout), do: fun.() 41 | end 42 | --------------------------------------------------------------------------------