├── .ci ├── assets │ └── api-keys.txt └── docker-compose.yml ├── .github └── workflows │ ├── erlang.yml │ └── release.yml ├── .gitignore ├── .tool-versions ├── LICENSE ├── Makefile ├── README.md ├── docs ├── images │ ├── plugin-install.png │ ├── plugin-list-empty.png │ ├── plugin-list-installed.png │ └── sample-form.png └── ui_declarations.md ├── emqx-plugin-templates ├── .gitignore ├── .tool-versions ├── LICENSE ├── Makefile ├── README.md ├── erlang_ls.config ├── priv │ ├── config.hocon.example │ ├── config_i18n.json.example │ ├── config_schema.avsc.enterprise.example │ └── config_schema.avsc.example ├── rebar.config ├── scripts │ ├── ensure-rebar3.sh │ └── get-otp-vsn.sh └── src │ ├── emqx_plugin_template.app.src │ ├── emqx_plugin_template.erl │ ├── emqx_plugin_template_app.erl │ ├── emqx_plugin_template_cli.erl │ └── emqx_plugin_template_sup.erl ├── emqx-plugin.template ├── erlang_ls.config ├── rebar.config ├── scripts ├── build-sample-plugin.sh ├── ensure-rebar3.sh ├── format-template-code.sh ├── get-otp-vsn.sh └── install-rebar-template.sh ├── src └── emqx_pt.app.src └── test ├── emqx_pt_SUITE.erl ├── emqx_pt_test_api_helpers.erl └── emqx_pt_test_helpers.erl /.ci/assets/api-keys.txt: -------------------------------------------------------------------------------- 1 | key:secret 2 | -------------------------------------------------------------------------------- /.ci/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | emqx: 3 | image: emqx/emqx-enterprise:5.9.0 4 | container_name: emqx 5 | environment: 6 | EMQX_LOG__CONSOLE__LEVEL: debug 7 | EMQX_API_KEY__BOOTSTRAP_FILE: "/opt/emqx-bootstrap/api-keys.txt" 8 | ports: 9 | - "1883:1883" 10 | - "8083:8083" 11 | - "18083:18083" 12 | networks: 13 | - emqx_network 14 | healthcheck: 15 | test: ["CMD", "/opt/emqx/bin/emqx", "ctl", "status"] 16 | interval: 5s 17 | timeout: 25s 18 | retries: 5 19 | volumes: 20 | - ./assets/api-keys.txt:/opt/emqx-bootstrap/api-keys.txt:ro 21 | 22 | networks: 23 | emqx_network: 24 | driver: bridge 25 | -------------------------------------------------------------------------------- /.github/workflows/erlang.yml: -------------------------------------------------------------------------------- 1 | name: Erlang CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | ## We purposely do not use docker image for building, 16 | ## thus verifying that .tool-versions is suitable for developing a plugin. 17 | - name: Setup Erlang 18 | uses: erlef/setup-beam@v1 19 | with: 20 | version-file: emqx-plugin-templates/.tool-versions 21 | version-type: strict 22 | - name: Check template code formatting 23 | run: make fmt-template-check 24 | - name: Check formatting of the project code (tests, rebar.config, etc) 25 | run: make fmt-check 26 | - name: Install rebar3 template 27 | run: make install-rebar-template 28 | - name: Build test plugins 29 | run: make build-test-plugins 30 | - name: Start EMQX 31 | run: make up 32 | - name: Run tests 33 | run: make ct 34 | - name: Stop EMQX 35 | if: always() 36 | run: make down 37 | 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: build release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build_release: 10 | if: startsWith(github.ref, 'refs/tags/') 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Setup Erlang 15 | uses: erlef/setup-beam@v1 16 | with: 17 | version-file: emqx-plugin-templates/.tool-versions 18 | version-type: strict 19 | - name: Ensure rebar3 20 | run: make ensure-rebar3 21 | - name: Install rebar3 template 22 | run: make install-rebar-template 23 | - name: build plugins 24 | run: | 25 | ./scripts/build-sample-plugin.sh --tag ${{ github.ref_name }} --name my_emqx_plugin_avsc --with-avsc --output-dir build 26 | ./scripts/build-sample-plugin.sh --tag ${{ github.ref_name }} --name my_emqx_plugin --output-dir build 27 | - uses: actions/upload-artifact@v4 28 | with: 29 | name: packages 30 | path: | 31 | build/*.tar.gz 32 | 33 | release: 34 | if: startsWith(github.ref, 'refs/tags/') 35 | runs-on: ubuntu-latest 36 | needs: 37 | - build_release 38 | steps: 39 | - uses: actions/download-artifact@v4 40 | with: 41 | name: packages 42 | path: packages 43 | - name: Create Release 44 | uses: actions/create-release@v1 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | with: 48 | tag_name: ${{ github.ref }} 49 | release_name: EMQX5 Example Plugin ${{ github.ref_name }} Released 50 | body: EMQX5 Example Plugin ${{ github.ref_name }} Released 51 | draft: false 52 | prerelease: false 53 | - uses: Rory-Z/upload-release-asset@v1 54 | with: 55 | repo: emqx-plugin-template 56 | path: "packages/*.tar.gz" 57 | token: ${{ github.token }} 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .eunit 2 | deps 3 | *.o 4 | *.beam 5 | *.plt 6 | erl_crash.dump 7 | ebin 8 | rel/example_project 9 | .concrete/DEV_MODE 10 | .rebar 11 | .erlang.mk/ 12 | data/ 13 | emqx_plugin_template.d 14 | .DS_Store 15 | erlang.mk 16 | _build/ 17 | rebar.lock 18 | test/ct.cover.spec 19 | .rebar3 20 | /.idea/ 21 | rebar3.crashdump 22 | rebar3 23 | erlang_ls.config 24 | # VSCode files 25 | .vs/ 26 | .vscode/ 27 | # Emacs Backup files 28 | *~ 29 | # Emacs temporary files 30 | .#* 31 | *# 32 | # For direnv 33 | .envrc 34 | test/assets/*.tar.gz 35 | 36 | my_emqx_plugin* 37 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.2-2 2 | elixir 1.18.3-otp-27 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ## to build emqtt without QUIC 2 | export BUILD_WITHOUT_QUIC = 1 3 | 4 | ## Feature Used in rebar plugin emqx_plugrel 5 | ## The Feature have not enabled by default on OTP25 6 | export ERL_FLAGS ?= -enable-feature maybe_expr 7 | export DOCKER_COMPOSE_FILE = $(CURDIR)/.ci/docker-compose.yml 8 | 9 | REBAR = $(CURDIR)/rebar3 10 | SCRIPTS = $(CURDIR)/scripts 11 | 12 | TEST_ASSETS_DIR = $(CURDIR)/_build/test/lib/emqx_pt/test/assets 13 | 14 | .PHONY: all 15 | all: compile 16 | 17 | .PHONY: ensure-rebar3 18 | ensure-rebar3: 19 | @$(SCRIPTS)/ensure-rebar3.sh 20 | 21 | $(REBAR): 22 | $(MAKE) ensure-rebar3 23 | 24 | .PHONY: ct 25 | ct: $(REBAR) 26 | $(REBAR) as test ct -v --readable=true 27 | 28 | .PHONY: clean 29 | clean: clean 30 | @rm -rf _build 31 | @rm -f rebar.lock 32 | 33 | .PHONY: install-rebar-template 34 | install-rebar-template: 35 | $(SCRIPTS)/install-rebar-template.sh 36 | 37 | .PHONY: build-test-plugins 38 | build-test-plugins: $(REBAR) 39 | $(SCRIPTS)/build-sample-plugin.sh --tag 1.0.0 --name my_emqx_plugin_avsc --with-avsc --output-dir $(TEST_ASSETS_DIR) 40 | $(SCRIPTS)/build-sample-plugin.sh --tag 1.0.0 --name my_emqx_plugin --output-dir $(TEST_ASSETS_DIR) 41 | 42 | .PHONY: fmt 43 | fmt: $(REBAR) 44 | $(REBAR) fmt --verbose -w 45 | 46 | .PHONY: fmt-check 47 | fmt-check: $(REBAR) 48 | $(REBAR) fmt --verbose --check 49 | 50 | .PHONY: fmt-template 51 | fmt-template: $(REBAR) 52 | ./scripts/format-template-code.sh 53 | 54 | .PHONY: fmt-template-check 55 | fmt-template-check: $(REBAR) 56 | ./scripts/format-template-code.sh --check 57 | 58 | .PHONY: up 59 | up: 60 | docker compose -f .ci/docker-compose.yml up --detach --build --force-recreate 61 | 62 | .PHONY: down 63 | down: 64 | docker compose -f .ci/docker-compose.yml down --volumes 65 | 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Erlang CI](https://github.com/emqx/emqx-plugin-template/actions/workflows/erlang.yml/badge.svg)](https://github.com/emqx/emqx-plugin-template/actions/workflows/erlang.yml) 2 | 3 | # EMQX Plugin Template 4 | 5 | This is a [rebar3 template](https://rebar3.org/docs/tutorials/templates/#custom-templates) to ease creation of [EMQX](https://github.com/emqx/emqx) v5 Plugins in [Erlang](https://www.erlang.org/). 6 | 7 | The documentation refers to the EMQX of the versions `~> 5.9`. 8 | 9 | For EMQX `~> 4.3`, please see branch `emqx-v4`. 10 | 11 | For older EMQX versions, plugin development is no longer maintained. 12 | 13 | A plugin template for Elixir (experimental) can be found at https://github.com/emqx/emqx-elixir-plugin. 14 | 15 | ## What is a Plugin? 16 | 17 | A plugin is an Erlang application that runs inside the EMQX nodes. 18 | 19 | To be loaded into the nodes, a plugin must be compiled into a release (a single `.tar.gz` file). Then the release can be imported into EMQX using the Dashboard, REST API or CLI interfaces. 20 | 21 | After being loaded, a plugin can be 22 | * configured 23 | * started/stopped 24 | * unloaded 25 | 26 | On startup, a plugin usually registers some of its functions as EMQX _callbacks_ to modify or extend EMQX behavior. 27 | 28 | ## Plugin development 29 | 30 | ### Prerequisites 31 | 32 | + A working build environment (e.g. `build_essential`) including `make`. 33 | + Erlang/OTP of the same major version as the EMQX release you want to target. See `org.opencontainers.image.otp.version` attribute of the docker, or refer to the `.tool-versions` file of the used version (e.g. https://github.com/emqx/emqx/blob/e5.9.0-beta.4/.tool-versions). We recommend using [ASDF](https://asdf-vm.com/) to manage your Erlang/OTP versions. 34 | + [rebar3](https://www.rebar3.org/) 35 | 36 | ### Plugin creation 37 | 38 | To create a new plugin, `emqx-plugin-template` (this repository) should be installed as a `rebar3` template. 39 | 40 | E.g. for a Linux system, the following commands should be executed: 41 | 42 | ```shell 43 | $ mkdir -p ~/.config/rebar3/templates 44 | $ pushd ~/.config/rebar3/templates 45 | $ git clone https://github.com/emqx/emqx-plugin-template.git 46 | $ popd 47 | ``` 48 | 49 | > [!NOTE] 50 | > If the `REBAR_CACHE_DIR` environment variable has been set, the directory for templates should be `$REBAR_CACHE_DIR/.config/rebar3/templates`. 51 | > [Here](https://github.com/erlang/rebar3/issues/2762) is a relevant issue. 52 | 53 | 54 | Then, the plugin can be created with the following command: 55 | 56 | ```shell 57 | $ rebar3 new emqx-plugin my_emqx_plugin 58 | ``` 59 | 60 | This will create a working skeleton of a plugin in the `my_emqx_plugin` directory with the same name. 61 | 62 | To make a release of the plugin, the following command should be executed: 63 | 64 | ```shell 65 | $ cd my_emqx_plugin 66 | $ make rel 67 | ``` 68 | 69 | This will create the plugin release: `_build/default/emqx_plugin/my_emqx_plugin-1.0.0.tar.gz`. This package may be used for provisioning/installation of the plugin. 70 | 71 | ### Plugin structure 72 | 73 | The plugin skeleton created by the `rebar3 new emqx-plugin` represents a single OTP application. 74 | 75 | ``` 76 | . 77 | ├── Makefile 78 | ├── README.md 79 | ├── rebar.config 80 | ├── scripts 81 | │   ├── ... 82 | ├── priv 83 | │   ├── ... 84 | └── src 85 | ├── my_emqx_plugin_app.erl 86 | ├── my_emqx_plugin.app.src 87 | ├── my_emqx_plugin_cli.erl 88 | ├── my_emqx_plugin.erl 89 | └── my_emqx_plugin_sup.erl 90 | ``` 91 | 92 | * `Makefile` - The entry point for building the plugin. 93 | * `README.md` - Documentation placeholder. 94 | * `rebar.config` - The rebar3 configuration file used to build the application and pack it into a release. 95 | * `scripts` - Helper scripts for the `Makefile`. 96 | * `priv` - The directory for the plugin's configuration files and configuration schema. It contains some example files. 97 | * `src` - The code of the plugin's OTP application. 98 | 99 | #### `rebar.config` 100 | 101 | The `rebar.config` file is used to build the plugin and pack it into a release. 102 | 103 | The most important sections are 104 | * dependencies (`deps`) section; 105 | * release section (`relx`); 106 | * plugin description (`emqx_plugin`) section. 107 | 108 | In the `deps` section, you can add dependencies to other OTP applications that your plugin depends on. 109 | 110 | ```erlang 111 | {deps, 112 | [ 113 | {emqx_plugin_helper, {git, "https://github.com/emqx/emqx-plugin-helper.git", {tag, "v5.9.0"}}} 114 | %% more dependencies 115 | ]}. 116 | ``` 117 | 118 | The skeleton adds an extra dependency to the plugin: `emqx_plugin_helper`. 119 | It is usually needed for plugin code to make use of the record definitions and macros provided in the header files. 120 | See [`rebar3` dependency documentation](https://www.rebar3.org/docs/configuration/dependencies/) for more details. 121 | 122 | In the `relx` section, you specify the release name and version, and the list of applications to be included in the release. 123 | 124 | ```erlang 125 | {relx, [ {release, {my_emqx_plugin, "1.0.0"}, 126 | [ my_emqx_plugin 127 | , emqx_plugin_helper 128 | ]} 129 | ... 130 | ]}. 131 | ``` 132 | 133 | Normally, you would like to add the applications of the runtime dependencies from the `deps` section to the release. 134 | 135 | The release name and version are important because they are used to identify the plugin when it is installed into EMQX. They form a single identifier for the plugin (`my_emqx_plugin-1.0.0`) by which it is addressed in the API or CLI. 136 | 137 | In the plugin description section, you specify additional information about the plugin. 138 | 139 | ```erlang 140 | {emqx_plugrel, 141 | [ {authors, ["Your Name"]} 142 | , {builder, 143 | [ {name, "Your Name"} 144 | , {contact, "your_email@example.cpm"} 145 | , {website, "http://example.com"} 146 | ]} 147 | , {repo, "https://github.com/emqx/emqx-plugin-template"} 148 | , {functionality, ["Demo"]} 149 | , {compatibility, 150 | [ {emqx, "~> 5.0"} 151 | ]} 152 | , {description, "Another amazing EMQX plugin"} 153 | ] 154 | } 155 | ``` 156 | 157 | #### `src` directory 158 | 159 | The `src` directory contains the code of the plugin's OTP application. 160 | 161 | ##### `my_emqx_plugin.app.src` 162 | 163 | `my_emqx_plugin.app.src` is a standard Erlang application description file which is compiled into `my_emqx_plugin.app` file in the release. 164 | 165 | Note the following: 166 | * The version of the application has nothing to do with the release version and may follow a different convention. 167 | The version of the plugin is specified in the `rebar.config` file. 168 | * Pay attention to the `applications` section. Since the plugin is an OTP application, plugin's start/stop/restart is 169 | the respective operation on the plugin's application. So, if the plugin's application depends on other applications, 170 | it should list them in the `applications` section. 171 | 172 | ##### `my_emqx_plugin_app.erl` 173 | 174 | `my_emqx_plugin_app.erl` is the main module of the plugin's OTP application implementing the [`application` behaviour](https://www.erlang.org/doc/man/application.html), mainly, `start/2` and `stop/1` functions used to start/stop the plugin's application and its supervison tree. 175 | 176 | Normally, the following activities are performed in the `start/2` function: 177 | * Hook into EMQX hookpoints. 178 | * Register CLI commands. 179 | * Start the supervision tree. 180 | 181 | Optionally, the `_app.erl` module may implement the `on_config_changed/2` and `on_health_check/1` callback functions. 182 | * `on_config_changed/2` is called when the plugin's configuration is changed via the Dashboard, API or CLI. 183 | * `on_health_check/1` is called when the plugin's info is requested. A plugin may report its status from this function. 184 | 185 | ##### Other files 186 | 187 | The `my_emqx_plugin_cli.erl` module implements the CLI commands of the plugin. When registered, CLI commands are may be called via `emqx ctl` command. 188 | 189 | `my_emqx_plugin_sup.erl` implements a typical supervisor for the plugin. 190 | 191 | `my_emqx_plugin.erl` is the main module of the plugin implementing the plugin's logic. In the skeleton, it implements several demonstrational hooks with simple logging. Any other modules may be added to the plugin. 192 | 193 | > [!NOTE] 194 | > The application modules and files may be arbitrarily named with the only requirements: 195 | > * The application name must be the same as the plugin name. 196 | > * The application module (`_app`) must be named as `{plugin_name}_app`. 197 | 198 | #### `priv` directory 199 | 200 | The `priv` directory contains the files for the plugin's configuration and configuration schema. 201 | 202 | In the skeleton, sample files are provided for them. 203 | 204 | ##### `config.hocon` 205 | 206 | The `config.hocon` file contains the plugin's initial configuration, i.e. the configuration that is used when the plugin is installed. It is written in [HOCON format](https://github.com/lightbend/config/blob/master/HOCON.md). 207 | 208 | You could use `config.hocon.example` for a quick reference. 209 | 210 | ##### `config_schema.avsc` 211 | 212 | The `config_schema.avsc` file contains the schema of the plugin's configuration. It is written in [Avro](https://avro.apache.org/docs/1.11.1/specification/) format. 213 | 214 | If this file is present, then any time the plugin's configuration is to be updated, EMQX will validate the new configuration and reject it if it does not match the schema. See `config_schema.avsc.example`. 215 | 216 | Also, the building the release will fail if the vendored `config.hocon` file does not conform to the schema. 217 | 218 | Additionally, the `config_schema.avsc` file may contain UI hints for the configuration. Then it will be possible to interactively configure the plugin's parameters via the EMQX Dashboard. See `config_schema.avsc.enterprise.example` for the reference. 219 | 220 | ##### `config_i18n.json` 221 | 222 | The `config_i18n.json` file contains the translations for the plugin's configuration UI. It is written in JSON format: 223 | 224 | ```json 225 | { 226 | "$key": { 227 | "zh": "中文翻译", 228 | "en": "English translation" 229 | }, 230 | ... 231 | } 232 | ``` 233 | 234 | The translations may be referenced in the `config_schema.avsc` in UI hints. See `config_i18n.json.example` and `config_schema.avsc.enterprise.example`. 235 | 236 | ### Package structure 237 | 238 | When a plugin is built into a release, the package structure is as follows: 239 | 240 | ``` 241 | └── my_emqx_plugin-1.1.0.tar.gz 242 |    ├── emqx_plugin_helper_vsn-5.9.0 243 |    ├── my_emqx_plugin-0.1.0 244 |    ├── README.md 245 |    └── release.json 246 | ``` 247 | 248 | I.e. the tarball contains the compiled applications (listed in the `relx` section of the `rebar.config` file), `README.md` and `release.json` file which contains various plugin metadata: 249 | 250 | ```json 251 | { 252 | "hidden": false, 253 | "name": "my_emqx_plugin", 254 | "description": "Another amazing EMQX plugin.", 255 | "authors": "Anonymous", 256 | "builder": { 257 | "name": "Anonymous", 258 | "contact": "anonymous@example.org", 259 | "website": "http://example.com" 260 | }, 261 | "repo": "https://github.com/emqx/emqx-plugin-template", 262 | "functionality": "Demo", 263 | "compatibility": { 264 | "emqx": "~> 5.7" 265 | }, 266 | "git_ref": "unknown", 267 | "built_on_otp_release": "27", 268 | "emqx_plugrel_vsn": "0.5.1", 269 | "git_commit_or_build_date": "2025-04-29", 270 | "metadata_vsn": "0.2.0", 271 | "rel_apps": [ 272 | "my_emqx_plugin-0.1.0", 273 | "emqx_plugin_helper-5.9.0" 274 | ], 275 | "rel_vsn": "1.1.0", 276 | "with_config_schema": true 277 | } 278 | ``` 279 | 280 | ## Plugin lifecycle 281 | 282 | A plugin has three main states in EMQX: 283 | * `installed` - the plugin is installed, its configuration and code are loaded, but the plugin's application is not started. 284 | * `started` - the plugin is installed and its application is started. 285 | * `uninstalled` - the plugin is uninstalled. 286 | 287 | ### Installation 288 | 289 | The installation process is as follows: 290 | 291 | 1. The plugin package (the tarball created by the `make rel` command) is uploaded via the Dashboard, API or CLI. 292 | 2. The plugin package is transferred to each node of the EMQX cluster. 293 | 3. On each node: 294 | * The plugin package saved in the `plugins` subdirectory in the EMQX root directory (this may be overridden by the `plugins.install_dir` option): `$EMQX_ROOT/plugins/my_emqx_plugin-1.0.0.tar.gz` 295 | * The plugin package is unpacked into the same directory: `$EMQX_ROOT/plugins/my_emqx_plugin-1.0.0/`. 296 | * The plugin's initial configuration (`config.hocon` from the main plugin's app) is copied into the `$EMQX_DATA_DIR/plugins/my_emqx_plugin/config.hocon` file. 297 | * The plugin config's avro schema is loaded (if present). 298 | * The plugin's code is loaded into the node, but not started. 299 | * The plugin is registered as `disabled` in the EMQX config (`plugins.states`). 300 | 301 | > [!NOTE] 302 | > For plugins, only plugin states (`true` or `false` for the `enable` flag) reside in the EMQX config. The plugin's 303 | configuration is stored in the `$EMQX_DATA_DIR/plugins/my_emqx_plugin/config.hocon` file on the nodes. 304 | 305 | ### Configuration 306 | 307 | After the plugin is installed, it may be configured via the Dashboard or API. On configuration change 308 | 309 | * The configuration is validated against the avro schema (if present). 310 | * The new configuration is sent to the nodes of the EMQX cluster. 311 | * The plugin's `on_config_changed/2` callback function is called. If the plugin accepts the new configuration, it is persisted in the `$EMQX_DATA_DIR/plugins/my_emqx_plugin/config.hocon` file. 312 | 313 | > [!NOTE] 314 | > `on_config_changed/2` callback function is called even if the application is not started. 315 | 316 | > [!NOTE] 317 | > `on_config_changed/2` callback function is called on each node of the EMQX cluster. So avoid implementing checks that may succeed or fail depending on the environment, e.g. do not check if some network resource is available. This may result to a situation when some nodes receive new configuration while others don't. Instead, use the `on_health_check/1` callback function for such checks and report unhealthy status if some resource is not available. 318 | 319 | ### Starting the plugin 320 | 321 | The plugin is started manually via the Dashboard, API, or CLI. On start, 322 | * The plugin's applications is started. 323 | * The plugin is registered as `enabled` in the EMQX config (`plugins.states`). 324 | 325 | When the plugin is started and its information is requested, the `on_health_check/1` callback function is called to retreive the plugin's status. 326 | 327 | ### Stopping the plugin 328 | 329 | When the plugin is stopped, 330 | * The plugin's applications are stopped. 331 | * The plugin is registered as `disabled` in the EMQX config (`plugins.states`). 332 | 333 | The plugin's code still remains loaded into the node, because a stopped plugin is still may be configured. 334 | 335 | ### Uninstallation 336 | 337 | The uninstallation process is the following: 338 | 339 | 1. The plugin is stopped (if it is running). 340 | 2. The plugin's code is unloaded from the node. 341 | 3. The plugin's package files are removed from the nodes (the config file is preserved). 342 | 4. The plugin is unregistered in the EMQX config (`plugins.states`). 343 | 344 | ### Joining the cluster 345 | 346 | When an EMQX node joins the cluster it may not have the actual plugins installed and configured since the plugins and their configs reside in the local file systems of the nodes. 347 | 348 | The new node does the following: 349 | 350 | * When a node joins the cluster, it obtains the global EMQX config (as a part of the cluster join process). 351 | * From the EMQX config, it knows plugin statuses (which plugins are installed and which are enabled). 352 | * The new node requests the plugins and their actual configs from other nodes. 353 | * The new node installs plugins and starts the enabled ones. 354 | 355 | ## Implementing a Plugin 356 | 357 | To implement a plugin, we usually need to implement the following logic. 358 | 359 | * Implementing hooks and CLI commands. 360 | * Handling configuration updates. 361 | * Handling health checks. 362 | 363 | ### Implementing hooks and CLI commands 364 | 365 | For certain events in EMQX, hookpoints are defined. Any application (including a plugin) may register callbacks for these hookpoints to react to the events and maybe alter the default behavior. 366 | 367 | The most useful hookpoints are exposed in the skeleton file. The full list of hookpoints with their arguments and expected return values is available in [EMQX code](https://github.com/emqx/emqx/blob/master/apps/emqx/src/emqx_hookpoints.erl). 368 | 369 | To register a callback for a hookpoint, we need to call the `emqx_hooks:add/3` function. 370 | We provide: 371 | * The hookpoint name. 372 | * The callback module and function, maybe with some arguments that will be passed from EMQX to the callback as the last arguments. 373 | * The priority of the callback. We usually use `?HP_HIGHEST` priority to be called first. 374 | 375 | To unregister a callback, we need to call the `emqx_hooks:del/2` function providing the hookpoint name and the callback module and function. 376 | 377 | For example, to register/unregister callbacks for the `client.authenticate` and `client.authorize` hookpoints, we may use the following code: 378 | 379 | ```erlang 380 | -module(my_emqx_plugin). 381 | ... 382 | hook() -> 383 | emqx_hooks:add('client.authenticate', {?MODULE, on_client_authenticate, []}, ?HP_HIGHEST), 384 | emqx_hooks:add('client.authorize', {?MODULE, on_client_authorize, []}, ?HP_HIGHEST). 385 | 386 | unhook() -> 387 | emqx_hooks:del('client.authenticate', {?MODULE, on_client_authenticate}), 388 | emqx_hooks:del('client.authorize', {?MODULE, on_client_authorize}). 389 | ``` 390 | 391 | Usually we want the hooks to be enabled/disabled together with the plugin, so we call hook/unhook in the `start/2` and `stop/1` functions of the plugin's application. E.g. 392 | 393 | ```erlang 394 | start(_StartType, _StartArgs) -> 395 | {ok, Sup} = my_emqx_plugin_sup:start_link(), 396 | my_emqx_plugin:hook(), 397 | emqx_ctl:register_command(my_emqx_plugin, {my_emqx_plugin_cli, cmd}), 398 | {ok, Sup}. 399 | 400 | stop(_State) -> 401 | emqx_ctl:unregister_command(my_emqx_plugin), 402 | my_emqx_plugin:unhook(). 403 | ``` 404 | 405 | From the hookpoint specification, we know the [signature of the callback functions](https://github.com/emqx/emqx/blob/master/apps/emqx/src/emqx_hookpoints.erl). 406 | 407 | ```erlang 408 | -callback 'client.authorize'( 409 | emqx_types:clientinfo(), emqx_types:pubsub(), emqx_types:topic(), allow | deny 410 | ) -> 411 | fold_callback_result(#{result := allow | deny, from => term()}). 412 | 413 | -callback 'client.authenticate'(emqx_types:clientinfo(), ignore) -> 414 | fold_callback_result( 415 | ignore 416 | | ok 417 | | {ok, map()} 418 | | {ok, map(), binary()} 419 | | {continue, map()} 420 | | {continue, binary(), map()} 421 | | {error, term()} 422 | ). 423 | ``` 424 | 425 | So we may implement some callback functions in the following way: 426 | 427 | ```erlang 428 | %% Only allow connections with client IDs that match any of the characters: A-Z, a-z, 0-9, and underscore. 429 | on_client_authenticate(#{clientid := ClientId} = _ClientInfo, DefaultResult) -> 430 | ClientIdRE = "^[A-Za-z0-9_]+$", 431 | case re:run(ClientId, ClientIdRE, [{capture, none}]) of 432 | match -> {ok, DefaultResult}; 433 | nomatch -> {stop, {error, bad_username_or_password}} 434 | end. 435 | 436 | %% Clients can only subscribe to topics formatted as /room/{clientid}, but can send messages to any topics. 437 | on_client_authorize( 438 | _ClientInfo = #{clientid := ClientId}, #{action_type := subscribe} = Action, Topic, _Result 439 | ) -> 440 | case emqx_topic:match(Topic, <<"room/", ClientId/binary>>) of 441 | true -> ignore; 442 | false -> {stop, #{result => deny, from => ?MODULE}} 443 | end; 444 | on_client_authorize(_ClientInfo = #{clientid := ClientId}, Action, Topic, _Result) -> 445 | ignore. 446 | ``` 447 | 448 | ### Handling configuration updates 449 | 450 | When a user changes the plugin's configuration, the `on_config_changed/2` callback function of the plugin's application module is called. 451 | 452 | Normally, we need to implement the following responsibilities in the `on_config_changed/2` callback function: 453 | * Validate the new configuration. 454 | * React to the configuration changes if the plugin is running. 455 | 456 | When validating the new configuration, we need take into account that the apllication may not be started yet. So we should use mostly stateless checks. We should also avoid checks depending on the environment, because the environment may be different on different nodes. 457 | 458 | To react to the configuration changes, we should only do something if the plugin is running. 459 | 460 | So the pattern is usually the following: 461 | 462 | * On application start, we start some `gen_server` process to handle the configuration. 463 | * The process is registered under some name, e.g. `my_emqx_plugin`. 464 | * The process reads the current configuration on start (see `my_emqx_plugin:init/1` in the skeleton app) 465 | and initializes its state. 466 | * `on_config_changed/2` callback function 467 | ** validates the new configuration 468 | ** casts a message to the `my_emqx_plugin` with the new configuration. 469 | * The `my_emqx_plugin` process 470 | ** if started, updates its state and reacts to the configuration changes 471 | ** if stopped (together with the application), nothing happens. 472 | 473 | ### Handling health checks 474 | 475 | The `on_health_check/1` callback function is called when the plugin's info is requested. A plugin may report its status from this function. 476 | 477 | If a plugin uses some external resources that may be unavailable, the function is a good place to inform EMQX about their availability. 478 | 479 | The function should return either 480 | * `ok` - if the plugin is healthy; 481 | * `{error, Reason}` with binary reason describing the problem if the plugin is unhealthy. 482 | 483 | See `my_emqx_plugin_app:on_health_check/1` in the skeleton app. 484 | 485 | > [!NOTE] 486 | > Although the function is called only for the running plugins, it also may be called for starting/stopping plugins due to concurrency. 487 | 488 | ## Declarative UI Description (Optional) 489 | 490 | If a plugin provides an avro schema for config validation, it may enrich the avro schema with UI declarations using the special `ui` field. 491 | 492 | Declarative UI components enable dynamic form rendering within the Dashboard, accommodating a variety of field types and custom components. Below is a description of the available components and their configurations. 493 | 494 | UI declarations are used for dynamic form rendering, allowing the EMQX Dashboard to dynamically generate configuration forms, making it easier to configure and manage plugins. Various field types and custom components are supported. Below are the available components and their configuration descriptions. 495 | 496 | For example, for the following config: 497 | 498 | ``` 499 | hostname = "localhost" 500 | port = 3306 501 | 502 | connectionOptions = [ 503 | { 504 | optionName = "autoReconnect" 505 | optionType = "string" 506 | optionValue = "true" 507 | } 508 | ] 509 | 510 | auth { 511 | username = "admin" 512 | password { 513 | string = "Public123" 514 | } 515 | } 516 | ``` 517 | 518 | We may provide schema and UI hints (see `priv/config_schema.avsc.enterprise.example`) to have a dynamic form rendered in the Dashboard: 519 | 520 | ![Dynamic Form](./docs/images/sample-form.png) 521 | 522 | See [docs/ui_declarations.md](docs/ui_declarations.md) for more details. 523 | 524 | ## Plugin installation 525 | 526 | We assume that the plugin is already built and the tarball `my_emqx_plugin-1.0.0.tar.gz` is available. 527 | 528 | ### Using CLI 529 | 530 | To install the plugin using CLI we to do the following. 531 | 532 | On an EMQX node, copy the tarball to the EMQX plugins directory: 533 | 534 | ```shell 535 | $ cp my_emqx_plugin-1.0.0.tar.gz $EMQX_HOME/plugins 536 | ``` 537 | 538 | Then, install the plugin: 539 | 540 | ```shell 541 | $ emqx ctl plugins install my_emqx_plugin-1.0.0 542 | ``` 543 | 544 | Check plugin list: 545 | 546 | ```shell 547 | $ emqx ctl plugins list 548 | ``` 549 | 550 | Start/stop the plugin: 551 | 552 | ```shell 553 | $ emqx ctl plugins start my_emqx_plugin-1.0.0 554 | $ emqx ctl plugins stop my_emqx_plugin-1.0.0 555 | ``` 556 | 557 | Uninstall the plugin: 558 | 559 | ```shell 560 | $ emqx ctl plugins uninstall my_emqx_plugin-1.0.0 561 | ``` 562 | 563 | ### Using Dashboard 564 | 565 | For security reasons, even when installing a plugin from the Dashboard, we need to allow the installation with a CLI command. 566 | 567 | On an EMQX node, run the following command: 568 | 569 | ```shell 570 | $ emqx ctl plugins allow my_emqx_plugin-1.0.0 571 | ``` 572 | 573 | Then open the Dashboard and navigate to the "Plugins" page (Menu -> Cluster Settings -> Extensions -> Plugins). 574 | 575 | ![Plugin List Empty](./docs/images/plugin-list-empty.png) 576 | 577 | Click on the "Install" button and select the `my_emqx_plugin-1.0.0.tar.gz` file. 578 | 579 | ![Plugin Install](./docs/images/plugin-install.png) 580 | 581 | Note that there is an additional hint about the CLI command to allow the installation. 582 | 583 | Click on the "Install". You will see the list of plugins with the new plugin installed. 584 | 585 | ![Plugin List](./docs/images/plugin-list-installed.png) 586 | 587 | Now you can start/stop the plugin, configure it, etc. 588 | 589 | ### Using API 590 | 591 | To install the plugin using API, we need to do the following. 592 | 593 | First, allow the installation: 594 | 595 | ```shell 596 | emqx ctl plugins allow my_emqx_plugin-1.0.0 597 | ``` 598 | 599 | Then, install the plugin: 600 | 601 | ```bash 602 | $ curl -u $KEY:$SECRET -X POST http://$EMQX_HOST:18083/api/v5/plugins/install -H "Content-Type: multipart/form-data" -F "plugin=@my_emqx_plugin-1.0.0.tar.gz" 603 | ``` 604 | 605 | Check plugin list: 606 | 607 | ```bash 608 | $ curl -u $KEY:$SECRET http://$EMQX_HOST:18083/api/v5/plugins | jq 609 | ``` 610 | 611 | Start/stop the plugin: 612 | 613 | ```bash 614 | $ curl -s -u $KEY:$SECRET -X PUT "http://$EMQX_HOST:18083/api/v5/plugins/my_emqx_plugin-1.0.0/start" 615 | $ curl -s -u $KEY:$SECRET -X PUT "http://$EMQX_HOST:18083/api/v5/plugins/my_emqx_plugin-1.0.0/stop" 616 | ``` 617 | 618 | ## Plugin Upgrade 619 | 620 | EMQX does not allow several versions of the same plugin to be installed at the same time. 621 | 622 | So, to install a new version of the plugin, 623 | 624 | * The old version is uninstalled. 625 | * The new version installed. 626 | 627 | The configuration is preserved between the installations. 628 | 629 | 630 | 631 | -------------------------------------------------------------------------------- /docs/images/plugin-install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emqx/emqx-plugin-template/1895dee8068b0c9a5cf3d2f68ab9d8d6957c935c/docs/images/plugin-install.png -------------------------------------------------------------------------------- /docs/images/plugin-list-empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emqx/emqx-plugin-template/1895dee8068b0c9a5cf3d2f68ab9d8d6957c935c/docs/images/plugin-list-empty.png -------------------------------------------------------------------------------- /docs/images/plugin-list-installed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emqx/emqx-plugin-template/1895dee8068b0c9a5cf3d2f68ab9d8d6957c935c/docs/images/plugin-list-installed.png -------------------------------------------------------------------------------- /docs/images/sample-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emqx/emqx-plugin-template/1895dee8068b0c9a5cf3d2f68ab9d8d6957c935c/docs/images/sample-form.png -------------------------------------------------------------------------------- /docs/ui_declarations.md: -------------------------------------------------------------------------------- 1 | # Declarative UI Description 2 | 3 | ## Overview 4 | 5 | The Declarative UI Description is an optional feature that allows plugins to provide a declarative description of the UI components for their configuration. This feature enables the EMQX Dashboard to dynamically generate configuration forms, making it easier to configure and manage plugins. 6 | 7 | ## Configuration Item Descriptions 8 | 9 | - `component`
10 | Required. Specifies the component type for displaying and configuring data of different values and types. Supported components include: 11 | 12 | | Component Name | Description | 13 | | :----------------- | :----------------------------------------------------------- | 14 | | `input` | Text input box for short texts or strings | 15 | | `input-password` | Password input box that conceals input | 16 | | `input-number` | Numeric input box allowing only numeric input | 17 | | `input-textarea` | Text area for longer text entries | 18 | | `input-array` | Array input box for comma-separated values, supporting string and numeric arrays | 19 | | `switch` | Toggle switch for boolean values | 20 | | `select` | Dropdown selection box for enumerated types | 21 | | `code-editor` | Code editor for specific formats (e.g., SQL, JSON) | 22 | | `key-value-editor` | Editor for editing key-value pairs in Avro maps | 23 | | `maps-editor` | Editor for editing object arrays in Avro objects | 24 | - `label`
25 | Required. Defines the field's label or name, supports `$msgid` for internationalization. If i18n is not configured, the original text will be displayed directly. 26 | - `description`
27 | Optional. Provides a detailed description of the field, supports `$msgid` for internationalization. If i18n is not configured, the original text will be displayed directly. 28 | - `flex`
29 | Required. Defines the proportion of the field in the grid layout; a full grid (24) spans an entire row, while a half grid (12) covers half a row. 30 | - `required`
31 | Optional. Indicates whether the field is mandatory. 32 | - `format` (Applicable only for `code-editor` component)
33 | Optional. Specifies the supported data formats, such as `sql` or `json`. 34 | - `options` (Applicable only for `select` component)
35 | Optional. Lists the selectable options, aligned with the symbols in the Avro Schema. Example: 36 | 37 | ```json 38 | [ 39 | { 40 | "label": "$mysql", 41 | "value": "MySQL" 42 | }, 43 | { 44 | "label": "$pgsql", 45 | "value": "postgreSQL" 46 | } 47 | ] 48 | ``` 49 | - `items` (Applicable only for maps-editor component)
50 | Optional. When using the maps-editor component, specify the field name and description of the items in the form. For example: 51 | 52 | ```json 53 | { 54 | "items": { 55 | "optionName": { 56 | "label": "$optionNameLabel", 57 | "description": "$optionDesc", 58 | "type": "string" 59 | }, 60 | "optionValue": { 61 | "label": "$optionValueLabel", 62 | "description": "$optionValueDesc", 63 | "type": "string" 64 | } 65 | } 66 | } 67 | ``` 68 | - `rules`
69 | Optional. Defines validation rules for the field, where multiple rules can be configured. Supported types include: 70 | 71 | - `pattern`: Requires a regular expression for validation. 72 | - `range`: Validates numeric input within a specified range. This validation can be configured with both a minimum value (`min`) and a maximum value (`max`), which can be set either together or independently. 73 | - `length`: Validates the character count of input, ensuring it falls within a specified range. This validation rule allows for the configuration of both a minimum length (`minLength`) and a maximum length (`maxLength`), which can be set either together or individually. 74 | - `message`: Specifies an error message to display when validation fails. This supports internationalization using `$msgid` to accommodate multiple languages. 75 | 76 | ### Example Validation Rules 77 | 78 | The following are several example snippets. For more detailed examples, refer to `priv/config_schema.avsc.example`: 79 | 80 | ```json 81 | { 82 | "rules": [ 83 | { 84 | "type": "pattern", 85 | "pattern": "^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])(\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9]))*$", 86 | "message": "$hostname_validate" 87 | } 88 | ] 89 | } 90 | ``` 91 | 92 | ```json 93 | { 94 | "rules": [ 95 | { 96 | "type": "range", 97 | "min": 1, 98 | "max": 65535, 99 | "message": "$port_range_validate" 100 | } 101 | ] 102 | } 103 | ``` 104 | 105 | ```json 106 | { 107 | "rules": [ 108 | { 109 | "type": "length", 110 | "minLength": 8, 111 | "maxLength": 128, 112 | "message": "$password_length_validate" 113 | }, 114 | { 115 | "type": "pattern", 116 | "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]*$", 117 | "message": "$password_validate" 118 | } 119 | ] 120 | } 121 | ``` 122 | 123 | ## Localization 124 | 125 | There is also an **optional** internationalization (i18n) config file, located at `priv/config_i18n.json`. This file is structured as key-value pairs, for example: `{ "$msgid": { "zh": "消息", "en": "Message" } }`. 126 | To support multiple languages in field names, descriptions, validation rule messages, and other UI elements in the `$ui` configuration, use `$msgid` prefixed with `$` in the relevant UI configurations. 127 | 128 | -------------------------------------------------------------------------------- /emqx-plugin-templates/.gitignore: -------------------------------------------------------------------------------- 1 | .eunit 2 | deps 3 | *.o 4 | *.beam 5 | *.plt 6 | erl_crash.dump 7 | ebin 8 | rel/example_project 9 | .concrete/DEV_MODE 10 | .rebar 11 | .erlang.mk/ 12 | data/ 13 | emqx_plugin_template.d 14 | .DS_Store 15 | erlang.mk 16 | _build/ 17 | rebar.lock 18 | test/ct.cover.spec 19 | .rebar3 20 | /.idea/ 21 | rebar3.crashdump 22 | rebar3 23 | erlang_ls.config 24 | # VSCode files 25 | .vs/ 26 | .vscode/ 27 | # Emacs Backup files 28 | *~ 29 | # Emacs temporary files 30 | .#* 31 | *# 32 | # For direnv 33 | .envrc 34 | -------------------------------------------------------------------------------- /emqx-plugin-templates/.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.2 2 | elixir 1.18.3-otp-27 3 | -------------------------------------------------------------------------------- /emqx-plugin-templates/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /emqx-plugin-templates/Makefile: -------------------------------------------------------------------------------- 1 | export BUILD_WITHOUT_QUIC = 1 2 | 3 | ## Feature Used in rebar plugin emqx_plugrel 4 | ## The Feature have not enabled by default on OTP25 5 | export ERL_FLAGS ?= -enable-feature maybe_expr 6 | 7 | REBAR = $(CURDIR)/rebar3 8 | SCRIPTS = $(CURDIR)/scripts 9 | 10 | .PHONY: all 11 | all: compile 12 | 13 | .PHONY: ensure-rebar3 14 | ensure-rebar3: 15 | @$(SCRIPTS)/ensure-rebar3.sh 16 | 17 | $(REBAR): 18 | $(MAKE) ensure-rebar3 19 | 20 | .PHONY: compile 21 | compile: $(REBAR) 22 | $(REBAR) compile 23 | 24 | .PHONY: ct 25 | ct: $(REBAR) 26 | $(REBAR) as test ct -v 27 | 28 | .PHONY: eunit 29 | eunit: $(REBAR) 30 | $(REBAR) as test eunit 31 | 32 | .PHONY: cover 33 | cover: $(REBAR) 34 | $(REBAR) cover 35 | 36 | .PHONY: clean 37 | clean: distclean 38 | 39 | .PHONY: distclean 40 | distclean: 41 | @rm -rf _build 42 | @rm -f rebar.lock 43 | 44 | .PHONY: rel 45 | rel: $(REBAR) 46 | $(REBAR) emqx_plugrel tar 47 | 48 | .PHONY: fmt 49 | fmt: $(REBAR) 50 | $(REBAR) fmt --verbose -w 51 | -------------------------------------------------------------------------------- /emqx-plugin-templates/README.md: -------------------------------------------------------------------------------- 1 | {{=@@ @@=}} 2 | # @@name@@ 3 | 4 | @@description@@ 5 | 6 | ## Release 7 | 8 | An EMQX plugin release is a tar file including including a subdirectory of this plugin's name and it's version, that contains: 9 | 10 | 1. A JSON format metadata file describing the plugin 11 | 2. Versioned directories for all applications needed for this plugin (source and binaries). 12 | 3. Confirm the OTP version used by EMQX that the plugin will be installed on (See also [./.tool-versions](./.tool-versions)). 13 | 14 | In a shell from this plugin's working directory execute `make rel` to have the package created like: 15 | 16 | ``` 17 | _build/default/emqx_plugrel/@@name@@-.tar.gz 18 | ``` 19 | ## Format 20 | 21 | Format all the files in your project by running: 22 | ``` 23 | make fmt 24 | ``` 25 | 26 | See [EMQX documentation](https://docs.emqx.com/en/enterprise/v5.0/extensions/plugins.html) for details on how to deploy custom plugins. 27 | -------------------------------------------------------------------------------- /emqx-plugin-templates/erlang_ls.config: -------------------------------------------------------------------------------- 1 | apps_dirs: 2 | - "src/*" 3 | deps_dirs: 4 | - "_build/default/lib/*" 5 | include_dirs: 6 | - "_build/default/lib/*/include" 7 | exclude_unused_includes: 8 | - "typerefl/include/types.hrl" 9 | - "logger.hrl" 10 | diagnostics: 11 | enabled: 12 | - bound_var_in_pattern 13 | - elvis 14 | - unused_includes 15 | - unused_macros 16 | - compiler 17 | disabled: 18 | - dialyzer 19 | - crossref 20 | lenses: 21 | disabled: 22 | # - show-behaviour-usages 23 | # - ct-run-test 24 | - server-info 25 | enable: 26 | - show-behaviour-usages 27 | - ct-run-test 28 | macros: 29 | - name: EMQX_RELEASE_EDITION 30 | value: ce 31 | code_reload: 32 | node: emqx@127.0.0.1 33 | -------------------------------------------------------------------------------- /emqx-plugin-templates/priv/config.hocon.example: -------------------------------------------------------------------------------- 1 | ## This is a demo config in HOCON format 2 | ## The same format used by EMQX since 5.0 3 | 4 | client_regex = "^[A-Za-z0-9_]+$" 5 | 6 | ## The following parameters are not used by the demo plugin, 7 | ## they are just to demonstrate the format of the config and the schema 8 | 9 | hostname = "localhost" 10 | port = 3306 11 | 12 | connectionOptions = [ 13 | { 14 | optionName = "autoReconnect" 15 | optionType = "string" 16 | optionValue = "true" 17 | } 18 | ] 19 | 20 | auth { 21 | username = "admin" 22 | password { 23 | string = "Public123" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /emqx-plugin-templates/priv/config_i18n.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "$client_regex_label": { 3 | "zh": "客户端正则表达式", 4 | "en": "Client Regex" 5 | }, 6 | "$client_regex_desc": { 7 | "zh": "一个正则表达式,用于验证客户端ID。", 8 | "en": "A regex used to validate the clientid." 9 | }, 10 | "$hostname_label": { 11 | "zh": "主机名", 12 | "en": "Hostname" 13 | }, 14 | "$hostname_desc": { 15 | "zh": "主机名是一个标识符,用于识别网络上的设备。主机名通常是一个域名,例如:www.example.com。", 16 | "en": "The hostname is an identifier used to identify devices on a network. The hostname is usually a domain name, such as www.example.com." 17 | }, 18 | "$hostname_validate": { 19 | "zh": "主机名必须是一个有效的域名。", 20 | "en": "The hostname must be a valid domain name." 21 | }, 22 | "$port_label": { 23 | "zh": "端口", 24 | "en": "Port" 25 | }, 26 | "$port_desc": { 27 | "zh": "端口是一个数字,用于标识网络上的服务。常见的端口有:80(HTTP)、443(HTTPS)、21(FTP)。", 28 | "en": "The port is a number used to identify services on a network. Common ports include: 80 (HTTP), 443 (HTTPS), 21 (FTP)." 29 | }, 30 | "$port_range_validate": { 31 | "zh": "端口必须在 1 到 65535 之间。", 32 | "en": "The port must be between 1 and 65535." 33 | }, 34 | "$connection_options_label": { 35 | "en": "Connection Options", 36 | "zh": "连接选项" 37 | }, 38 | "$connection_options_desc": { 39 | "en": "A list of additional options for the database connection.", 40 | "zh": "数据库连接的附加选项列表。" 41 | }, 42 | "$username_label": { 43 | "en": "Username", 44 | "zh": "用户名" 45 | }, 46 | "$username_desc": { 47 | "zh": "连接数据库的用户名", 48 | "en": "The username used to connect to the database." 49 | }, 50 | "$password_label": { 51 | "en": "Password", 52 | "zh": "密码" 53 | }, 54 | "$password_desc": { 55 | "en": "The password used to connect to the database.", 56 | "zh": "连接数据库的密码。" 57 | }, 58 | "$password_length_validate": { 59 | "en": "The password must be at least 8 characters long.", 60 | "zh": "密码长度必须至少为 8 个字符。" 61 | }, 62 | "$password_validate": { 63 | "en": "The password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.", 64 | "zh": "密码必须包含至少一个大写字母、一个小写字母、一个数字和一个特殊字符。" 65 | }, 66 | "$option_name_label": { 67 | "en": "Option Name", 68 | "zh": "选项名称" 69 | }, 70 | "$option_name_desc": { 71 | "en": "The name of the connection option.", 72 | "zh": "连接选项的名称。" 73 | }, 74 | "$option_value_label": { 75 | "en": "Option Value", 76 | "zh": "选项值" 77 | }, 78 | "$option_value_desc": { 79 | "en": "The value of the connection option.", 80 | "zh": "连接选项的值。" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /emqx-plugin-templates/priv/config_schema.avsc.enterprise.example: -------------------------------------------------------------------------------- 1 | { 2 | "type": "record", 3 | "name": "ExtendedConfig", 4 | "fields": [ 5 | { 6 | "name": "client_regex", 7 | "type": "string", 8 | "default": "^[A-Za-z0-9_]+$", 9 | "$ui": { 10 | "component": "input", 11 | "flex": 12, 12 | "required": true, 13 | "label": "$client_regex_label", 14 | "description": "$client_regex_desc" 15 | } 16 | }, 17 | { 18 | "name": "hostname", 19 | "type": "string", 20 | "default": "localhost", 21 | "$ui": { 22 | "component": "input", 23 | "flex": 12, 24 | "required": true, 25 | "label": "$hostname_label", 26 | "description": "$hostname_desc", 27 | "rules": [ 28 | { 29 | "type": "pattern", 30 | "pattern": "^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])(\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9]))*$", 31 | "message": "$hostname_validate" 32 | } 33 | ] 34 | } 35 | }, 36 | { 37 | "name": "port", 38 | "type": "int", 39 | "default": 3306, 40 | "$ui": { 41 | "component": "input-number", 42 | "flex": 12, 43 | "required": true, 44 | "label": "$port_label", 45 | "description": "$port_desc", 46 | "rules": [ 47 | { 48 | "type": "range", 49 | "min": 1, 50 | "max": 65535, 51 | "message": "$port_range_validate" 52 | } 53 | ] 54 | } 55 | }, 56 | { 57 | "name": "connectionOptions", 58 | "type": { 59 | "type": "array", 60 | "items": { 61 | "type": "record", 62 | "name": "ConnectionOption", 63 | "fields": [ 64 | { 65 | "name": "optionName", 66 | "type": "string" 67 | }, 68 | { 69 | "name": "optionValue", 70 | "type": "string" 71 | }, 72 | { 73 | "name": "optionType", 74 | "type": "string" 75 | } 76 | ] 77 | } 78 | }, 79 | "default": [ 80 | { 81 | "optionName": "autoReconnect", 82 | "optionValue": "true", 83 | "optionType": "boolean" 84 | } 85 | ], 86 | "$ui": { 87 | "component": "maps-editor", 88 | "flex": 24, 89 | "items": { 90 | "optionName": { 91 | "label": "$option_name_label", 92 | "description": "$option_name_desc", 93 | "type": "string" 94 | }, 95 | "optionValue": { 96 | "label": "$option_value_label", 97 | "description": "$option_value_desc", 98 | "type": "string" 99 | } 100 | }, 101 | "label": "$connection_options_label", 102 | "description": "$connection_options_desc" 103 | } 104 | }, 105 | { 106 | "name": "auth", 107 | "type": { 108 | "type": "record", 109 | "name": "authConfigs", 110 | "fields": [ 111 | { 112 | "name": "username", 113 | "type": "string", 114 | "$ui": { 115 | "component": "input", 116 | "flex": 12, 117 | "required": true, 118 | "label": "$username_label", 119 | "description": "$username_desc" 120 | } 121 | }, 122 | { 123 | "name": "password", 124 | "type": [ 125 | "null", 126 | "string" 127 | ], 128 | "default": null, 129 | "$ui": { 130 | "component": "input-password", 131 | "flex": 12, 132 | "label": "$password_label", 133 | "description": "$password_desc", 134 | "rules": [ 135 | { 136 | "type": "length", 137 | "minLength": 8, 138 | "maxLength": 128, 139 | "message": "$password_length_validate" 140 | }, 141 | { 142 | "type": "pattern", 143 | "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]*$", 144 | "message": "$password_validate" 145 | } 146 | ] 147 | } 148 | } 149 | ] 150 | } 151 | } 152 | ] 153 | } 154 | -------------------------------------------------------------------------------- /emqx-plugin-templates/priv/config_schema.avsc.example: -------------------------------------------------------------------------------- 1 | { 2 | "type": "record", 3 | "name": "ExtendedConfig", 4 | "fields": [ 5 | { 6 | "name": "client_regex", 7 | "type": "string", 8 | "default": "^[A-Za-z0-9_]+$" 9 | }, 10 | { 11 | "name": "hostname", 12 | "type": "string", 13 | "default": "localhost" 14 | }, 15 | { 16 | "name": "port", 17 | "type": "int", 18 | "default": 3306 19 | }, 20 | { 21 | "name": "connectionOptions", 22 | "type": { 23 | "type": "array", 24 | "items": { 25 | "type": "record", 26 | "name": "ConnectionOption", 27 | "fields": [ 28 | { 29 | "name": "optionName", 30 | "type": "string" 31 | }, 32 | { 33 | "name": "optionValue", 34 | "type": "string" 35 | }, 36 | { 37 | "name": "optionType", 38 | "type": "string" 39 | } 40 | ] 41 | } 42 | }, 43 | "default": [ 44 | { 45 | "optionName": "autoReconnect", 46 | "optionValue": "true", 47 | "optionType": "boolean" 48 | } 49 | ] 50 | }, 51 | { 52 | "name": "auth", 53 | "type": { 54 | "type": "record", 55 | "name": "authConfigs", 56 | "fields": [ 57 | { 58 | "name": "username", 59 | "type": "string" 60 | }, 61 | { 62 | "name": "password", 63 | "type": [ 64 | "null", 65 | "string" 66 | ], 67 | "default": null 68 | } 69 | ] 70 | } 71 | } 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /emqx-plugin-templates/rebar.config: -------------------------------------------------------------------------------- 1 | {{=@@ @@=}} 2 | %% -*- mode: erlang -*- 3 | {deps, [ 4 | {emqx_plugin_helper, {git, "https://github.com/emqx/emqx-plugin-helper.git", {tag, "@@emqx_plugin_helper_vsn@@"}}} 5 | ]}. 6 | 7 | {plugins, [ 8 | {emqx_plugin_helper, {git, "https://github.com/emqx/emqx-plugin-helper.git", {tag, "@@emqx_plugin_helper_vsn@@"}}} 9 | ]}. 10 | 11 | {project_plugins, [{erlfmt, "1.6.0"}]}. 12 | 13 | {erl_opts, [debug_info]}. 14 | 15 | %% this is the release version, different from app vsn in .app file 16 | {relx, [ 17 | {release, {@@name@@, "@@version@@"}, [ 18 | @@name@@, 19 | emqx_plugin_helper 20 | ]}, 21 | {dev_mode, false}, 22 | {include_erts, false}, 23 | {include_src, false} 24 | ]}. 25 | 26 | %% Additional info of the plugin 27 | {emqx_plugrel, [ 28 | {authors, ["@@author_name@@"]}, 29 | {builder, [ 30 | {name, "@@author_name@@"}, 31 | {contact, "@@author_email@@"}, 32 | {website, "@@author_website@@"} 33 | ]}, 34 | {repo, "@@repo@@"}, 35 | {functionality, ["Demo"]}, 36 | {compatibility, [{emqx, "~> 5.7"}]}, 37 | {description, "@@description@@"} 38 | ]}. 39 | -------------------------------------------------------------------------------- /emqx-plugin-templates/scripts/ensure-rebar3.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | [ "${DEBUG:-0}" -eq 1 ] && set -x 6 | 7 | OTP_VSN="${OTP_VSN:-$(./scripts/get-otp-vsn.sh)}" 8 | case ${OTP_VSN} in 9 | 25*) 10 | VERSION="3.19.0-emqx-9" 11 | ;; 12 | 26*) 13 | VERSION="3.20.0-emqx-1" 14 | ;; 15 | 27*) 16 | VERSION="3.24.0-emqx-1" 17 | ;; 18 | *) 19 | echo "Unsupported Erlang/OTP version $OTP_VSN" 20 | exit 1 21 | ;; 22 | esac 23 | 24 | # ensure dir 25 | cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." 26 | 27 | DOWNLOAD_URL='https://github.com/emqx/rebar3/releases/download' 28 | 29 | download() { 30 | echo "downloading rebar3 ${VERSION}" 31 | curl -f -L "${DOWNLOAD_URL}/${VERSION}/rebar3" -o ./rebar3 32 | } 33 | 34 | # get the version number from the second line of the escript 35 | # because command `rebar3 -v` tries to load rebar.config 36 | # which is slow and may print some logs 37 | version() { 38 | head -n 2 ./rebar3 | tail -n 1 | tr ' ' '\n' | grep -E '^.+-emqx-.+' 39 | } 40 | 41 | if [ -f 'rebar3' ] && [ "$(version)" = "$VERSION" ]; then 42 | exit 0 43 | fi 44 | 45 | download 46 | chmod +x ./rebar3 47 | -------------------------------------------------------------------------------- /emqx-plugin-templates/scripts/get-otp-vsn.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | erl -noshell -eval '{ok, Version} = file:read_file(filename:join([code:root_dir(), "releases", erlang:system_info(otp_release), "OTP_VERSION"])), io:fwrite(Version), halt().' 6 | -------------------------------------------------------------------------------- /emqx-plugin-templates/src/emqx_plugin_template.app.src: -------------------------------------------------------------------------------- 1 | {{=@@ @@=}} 2 | {application, @@name@@, [ 3 | {description, "@@description@@"}, 4 | {vsn, "@@app_vsn@@"}, 5 | {modules, []}, 6 | {registered, [@@name@@_sup]}, 7 | {applications, [kernel, stdlib, emqx_plugin_helper]}, 8 | {mod, {@@name@@_app, []}}, 9 | {env, []}, 10 | {licenses, ["@@license@@"]}, 11 | {maintainers, ["@@author_name@@ <@@author_email@@>"]} 12 | ]}. 13 | -------------------------------------------------------------------------------- /emqx-plugin-templates/src/emqx_plugin_template.erl: -------------------------------------------------------------------------------- 1 | {{=@@ @@=}} 2 | -module(@@name@@). 3 | 4 | -define(PLUGIN_NAME, "@@name@@"). 5 | -define(PLUGIN_VSN, "@@version@@"). 6 | 7 | %% for #message{} record 8 | %% no need for this include if we call emqx_message:to_map/1 to convert it to a map 9 | -include_lib("emqx_plugin_helper/include/emqx.hrl"). 10 | 11 | %% for hook priority constants 12 | -include_lib("emqx_plugin_helper/include/emqx_hooks.hrl"). 13 | 14 | %% for logging 15 | -include_lib("emqx_plugin_helper/include/logger.hrl"). 16 | 17 | -export([ 18 | hook/0, 19 | unhook/0, 20 | start_link/0 21 | ]). 22 | 23 | -export([ 24 | on_config_changed/2, 25 | on_health_check/1, 26 | get_config/0 27 | ]). 28 | 29 | %% Hook callbacks 30 | -export([ 31 | on_client_connect/3, 32 | on_client_authenticate/2, 33 | on_client_authorize/4, 34 | on_message_puback/4 35 | ]). 36 | 37 | -export([ 38 | init/1, 39 | handle_call/3, 40 | handle_cast/2, 41 | handle_info/2, 42 | terminate/2 43 | ]). 44 | 45 | %% @doc 46 | %% Called when the plugin application start 47 | hook() -> 48 | %% NOTE: 49 | %% We use highest hook priority ?HP_HIGHEST so this module's callbacks 50 | %% are evaluated before the default hooks in EMQX 51 | %% 52 | %% We hook into several EMQX hooks for demonstration purposes. 53 | %% For the actual full list of hooks, their types and signatures see the EMQX code 54 | %% https://github.com/emqx/emqx/blob/master/apps/emqx/src/emqx_hookpoints.erl#L102 55 | %% 56 | %% We can pass additional arguments to the callback functions (see 'client.connect'). 57 | emqx_hooks:add('client.connect', {?MODULE, on_client_connect, [some_arg]}, ?HP_HIGHEST), 58 | emqx_hooks:add('client.authenticate', {?MODULE, on_client_authenticate, []}, ?HP_HIGHEST), 59 | emqx_hooks:add('client.authorize', {?MODULE, on_client_authorize, []}, ?HP_HIGHEST), 60 | emqx_hooks:add('message.puback', {?MODULE, on_message_puback, []}, ?HP_HIGHEST). 61 | 62 | %% @doc 63 | %% Called when the plugin stops 64 | unhook() -> 65 | emqx_hooks:del('client.connect', {?MODULE, on_client_connect}), 66 | emqx_hooks:del('client.authenticate', {?MODULE, on_client_authenticate}), 67 | emqx_hooks:del('client.authorize', {?MODULE, on_client_authorize}), 68 | emqx_hooks:del('message.puback', {?MODULE, on_message_puback}). 69 | 70 | %%-------------------------------------------------------------------- 71 | %% Hook callbacks 72 | %%-------------------------------------------------------------------- 73 | 74 | %% NOTE 75 | %% There are 2 types of hooks: fold hooks and run hooks. 76 | %% 77 | %% For run hooks, the registered callbacks are just sequentially called: 78 | %% * Until all of them are called; 79 | %% * Until one of them returns `stop`. In this case the subsequent callbacks 80 | %% are not called. 81 | %% 82 | %% For fold hooks, the registered callbacks are sequentially called with the 83 | %% accumulated value. The accumulated value is passed to the next callback as the 84 | %% last argument coming before custom arguments. 85 | %% The convention of the return values is the following: 86 | %% * `{stop, Acc}` - stop calling the subsequent callbacks, return `Acc` as the result. 87 | %% * `{ok, Acc}` - continue calling the subsequent callbacks, but use `Acc` as the new 88 | %% accumulated value. 89 | %% * `stop` - stop calling the subsequent callbacks, return current accumulator as the result. 90 | %% * any other value - continue calling the subsequent callbacks without changing the accumulator. 91 | 92 | %% The example of a run hook is the 'client.connected' hook. Callbacks for this hook 93 | %% are called when a client is connected to the broker, no action required. 94 | %% 95 | %% The example of a fold hook is the 'client.authenticate' hook. Callbacks for this hook 96 | %% are called when a client is authenticated, and may be used to handle authentication 97 | %% and provide authentication result. 98 | 99 | %% @doc 100 | %% This is a run hook callback. 101 | %% It does not do anything useful here, just demonstrates 102 | %% * receiving additional arguments from the hook registration; 103 | %% * using logging. 104 | on_client_connect(ConnInfo, Props, some_arg) -> 105 | %% NOTE 106 | %% EMQX structured-logging macros should be used for logging. 107 | %% 108 | %% * Recommended to always have a `msg` field, 109 | %% * Use underscore instead of space to help log indexers, 110 | %% * Try to use static fields 111 | ?SLOG(debug, #{ 112 | msg => "@@name@@_on_client_connect", 113 | conninfo => ConnInfo, 114 | props => Props 115 | }), 116 | {ok, Props}. 117 | 118 | %% @doc 119 | %% This a fold hook callback. 120 | %% 121 | %% - Return `{stop, ok}' if this client is to be allowed to login. 122 | %% - Return `{stop, {error, not_authorized}}' if this client is not allowed to login. 123 | %% - Return `ignore' if this client is to be authenticated by other plugins 124 | %% or EMQX's built-in authenticators. 125 | %% 126 | %% Here we check if the clientid matches the regex in the config. 127 | on_client_authenticate(#{clientid := ClientId} = _ClientInfo, DefaultResult) -> 128 | Config = get_config(), 129 | ClientIdRE = maps:get(<<"client_regex">>, Config, <<"">>), 130 | ?SLOG(debug, #{ 131 | msg => "@@name@@_on_client_authenticate", 132 | clientid => ClientId, 133 | clientid_re => ClientIdRE 134 | }), 135 | case re:run(ClientId, ClientIdRE, [{capture, none}]) of 136 | match -> {ok, DefaultResult}; 137 | nomatch -> {stop, {error, bad_username_or_password}} 138 | end. 139 | 140 | %% @doc 141 | %% This is a fold hook callback. 142 | %% 143 | %% - To permit/forbid actions, return `{stop, #{result => Result}}' where `Result' is either `allow' or `deny'. 144 | %% - Return `ignore' if this client is to be authorized by other plugins or 145 | %% EMQX's built-in authorization sources. 146 | %% 147 | %% Here we demonstrate the following rule: 148 | %% Clients can only subscribe to topics formatted as /room/{clientid}, but can send messages to any topics. 149 | on_client_authorize( 150 | _ClientInfo = #{clientid := ClientId}, #{action_type := subscribe} = Action, Topic, _Result 151 | ) -> 152 | ?SLOG(debug, #{ 153 | msg => "@@name@@_on_client_authorize_subscribe", 154 | clientid => ClientId, 155 | topic => Topic, 156 | action => Action 157 | }), 158 | case emqx_topic:match(Topic, <<"room/", ClientId/binary>>) of 159 | true -> ignore; 160 | false -> {stop, #{result => deny, from => ?MODULE}} 161 | end; 162 | on_client_authorize(_ClientInfo = #{clientid := ClientId}, Action, Topic, _Result) -> 163 | ?SLOG(debug, #{ 164 | msg => "@@name@@_on_client_authorize", 165 | clientid => ClientId, 166 | topic => Topic, 167 | action => Action 168 | }), 169 | ignore. 170 | 171 | %% @doc 172 | %% Demo callback working with messages. This is a fold hook callback. 173 | on_message_puback(_PacketId, #message{} = Message, PubRes, RC) -> 174 | NewRC = 175 | case RC of 176 | %% Demo: some service do not want to expose the error code (129) to client; 177 | %% so here it remap 129 to 128 178 | 129 -> 128; 179 | _ -> RC 180 | end, 181 | ?SLOG(debug, #{ 182 | msg => "@@name@@_on_message_puback", 183 | message => emqx_message:to_map(Message), 184 | pubres => PubRes, 185 | rc => NewRC 186 | }), 187 | {ok, NewRC}. 188 | 189 | %%-------------------------------------------------------------------- 190 | %% Plugin callbacks 191 | %%-------------------------------------------------------------------- 192 | 193 | %% @doc 194 | %% - Return `{error, Error}' if the health check fails. 195 | %% - Return `ok' if the health check passes. 196 | %% 197 | %% NOTE 198 | %% For demonstration, we consider any port number other than 3306 unavailable. 199 | on_health_check(_Options) -> 200 | case get_config() of 201 | #{<<"port">> := 3306} -> 202 | ok; 203 | #{<<"port">> := Port} -> 204 | {error, iolist_to_binary(io_lib:format("Port unavailable: ~p", [Port]))}; 205 | _ -> 206 | {error, <<"Invalid config, no port">>} 207 | end. 208 | 209 | %% @doc 210 | %% - Return `{error, Error}' if the new config is invalid. 211 | %% - Return `ok' if the config is valid and can be accepted. 212 | %% 213 | %% NOTE 214 | %% We validate only the client_regex field here. Other config fields are present 215 | %% only for the demonstration purposes. 216 | %% 217 | %% NOTE 218 | %% Take the following considerations into account when writing the validating callback: 219 | %% * You should not make validations depending on the environment, e.g. 220 | %% check network connection, file system, etc. 221 | %% * The callback may be called even if the application is not running. 222 | %% Here we use `gen_server:cast/2` to react on changes. The cast will be silently 223 | %% ignored if the plugin is not running. 224 | on_config_changed(_OldConfig, #{<<"client_regex">> := ClientRegex} = NewConfig) -> 225 | case re:compile(ClientRegex) of 226 | {ok, _} -> 227 | ok = gen_server:cast(?MODULE, {on_changed, NewConfig}); 228 | {error, Error} -> 229 | {error, iolist_to_binary(io_lib:format("~p", [Error]))} 230 | end; 231 | on_config_changed(_OldConfig, _NewConfig) -> 232 | {error, <<"Invalid config, no client_regex">>}. 233 | 234 | %%-------------------------------------------------------------------- 235 | %% Working with config 236 | %%-------------------------------------------------------------------- 237 | 238 | %% @doc 239 | %% Efficiently get the current config. 240 | get_config() -> 241 | persistent_term:get(?MODULE, #{}). 242 | 243 | %% gen_server callbacks 244 | 245 | start_link() -> 246 | gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). 247 | 248 | init([]) -> 249 | erlang:process_flag(trap_exit, true), 250 | PluginNameVsn = <>, 251 | Config = emqx_plugin_helper:get_config(PluginNameVsn), 252 | ?SLOG(debug, #{ 253 | msg => "@@name@@_init", 254 | config => Config 255 | }), 256 | persistent_term:put(?MODULE, Config), 257 | {ok, #{}}. 258 | 259 | handle_call(_Request, _From, State) -> 260 | {reply, ok, State}. 261 | 262 | handle_cast({on_changed, Config}, State) -> 263 | persistent_term:put(?MODULE, Config), 264 | %% NOTE 265 | %% additionally handling of the config change here, i.e 266 | %% reestablish the connection to the database in case of host change, etc. 267 | {noreply, State}; 268 | handle_cast(_Request, State) -> 269 | {noreply, State}. 270 | 271 | handle_info(_Request, State) -> 272 | {noreply, State}. 273 | 274 | terminate(_Reason, _State) -> 275 | persistent_term:erase(?MODULE), 276 | ok. 277 | -------------------------------------------------------------------------------- /emqx-plugin-templates/src/emqx_plugin_template_app.erl: -------------------------------------------------------------------------------- 1 | {{=@@ @@=}} 2 | -module(@@name@@_app). 3 | 4 | -behaviour(application). 5 | 6 | -emqx_plugin(?MODULE). 7 | 8 | -export([ 9 | start/2, 10 | stop/1 11 | ]). 12 | 13 | -export([ 14 | on_config_changed/2, 15 | on_health_check/1 16 | ]). 17 | 18 | start(_StartType, _StartArgs) -> 19 | {ok, Sup} = @@name@@_sup:start_link(), 20 | @@name@@:hook(), 21 | emqx_ctl:register_command(@@name@@, {@@name@@_cli, cmd}), 22 | {ok, Sup}. 23 | 24 | stop(_State) -> 25 | emqx_ctl:unregister_command(@@name@@), 26 | @@name@@:unhook(). 27 | 28 | on_config_changed(OldConfig, NewConfig) -> 29 | @@name@@:on_config_changed(OldConfig, NewConfig). 30 | 31 | on_health_check(Options) -> 32 | @@name@@:on_health_check(Options). 33 | -------------------------------------------------------------------------------- /emqx-plugin-templates/src/emqx_plugin_template_cli.erl: -------------------------------------------------------------------------------- 1 | {{=@@ @@=}} 2 | -module(@@name@@_cli). 3 | 4 | %% This is an example on how to extend `emqx ctl` with your own commands. 5 | 6 | -export([cmd/1]). 7 | 8 | cmd(["get-config"]) -> 9 | Config = @@name@@:get_config(), 10 | emqx_ctl:print("~s~n", [emqx_utils_json:encode(Config)]); 11 | cmd(_) -> 12 | emqx_ctl:usage([{"get-config", "get current config"}]). 13 | -------------------------------------------------------------------------------- /emqx-plugin-templates/src/emqx_plugin_template_sup.erl: -------------------------------------------------------------------------------- 1 | {{=@@ @@=}} 2 | -module(@@name@@_sup). 3 | 4 | -behaviour(supervisor). 5 | 6 | -export([start_link/0]). 7 | 8 | -export([init/1]). 9 | 10 | start_link() -> 11 | supervisor:start_link({local, ?MODULE}, ?MODULE, []). 12 | 13 | init([]) -> 14 | ConfigChildSpec = #{ 15 | id => @@name@@, 16 | start => {@@name@@, start_link, []}, 17 | restart => permanent, 18 | shutdown => 5000, 19 | type => worker, 20 | modules => [@@name@@] 21 | }, 22 | SupFlags = #{ 23 | strategy => one_for_all, 24 | intensity => 100, 25 | period => 10 26 | }, 27 | {ok, {SupFlags, [ConfigChildSpec]}}. 28 | -------------------------------------------------------------------------------- /emqx-plugin.template: -------------------------------------------------------------------------------- 1 | {description, "Create custom EMQX plugins in a snap!"}. 2 | {variables, [ 3 | {description, "Another amazing EMQX plugin."}, 4 | {version, "1.0.0", "The release version of this plugin."}, 5 | {app_vsn, "0.1.0", "The erlang application vsn value."}, 6 | {emqx_plugin_helper_vsn, "v5.9.0", "EMQX Plugin helper to use as a dependency."}, 7 | {license, "Apache-2.0", "Short identifier for license you want to distribute this plugin under."}, 8 | {author_website, "http://example.com", "A website with details about the author."}, 9 | {repo, "https://github.com/emqx/emqx-plugin-template", "Where to find the source code for this plugin."} 10 | ]}. 11 | {dir, "{{name}}/src"}. 12 | {dir, "{{name}}/priv"}. 13 | {dir, "{{name}}/scripts"}. 14 | {file, "emqx-plugin-templates/.tool-versions", "{{name}}/.tool-versions"}. 15 | {file, "emqx-plugin-templates/LICENSE", "{{name}}/LICENSE"}. 16 | {file, "emqx-plugin-templates/Makefile", "{{name}}/Makefile"}. 17 | {file, "emqx-plugin-templates/erlang_ls.config", "{{name}}/erlang_ls.config"}. 18 | 19 | {file, "emqx-plugin-templates/priv/config.hocon.example", "{{name}}/priv/config.hocon.example"}. 20 | {file, "emqx-plugin-templates/priv/config_schema.avsc.enterprise.example", "{{name}}/priv/config_schema.avsc.enterprise.example"}. 21 | {file, "emqx-plugin-templates/priv/config_schema.avsc.example", "{{name}}/priv/config_schema.avsc.example"}. 22 | {file, "emqx-plugin-templates/priv/config_i18n.json.example", "{{name}}/priv/config_i18n.json.example"}. 23 | 24 | {file, "emqx-plugin-templates/scripts/ensure-rebar3.sh", "{{name}}/scripts/ensure-rebar3.sh"}. 25 | {file, "emqx-plugin-templates/scripts/get-otp-vsn.sh", "{{name}}/scripts/get-otp-vsn.sh"}. 26 | {file, "emqx-plugin-templates/.gitignore", "{{name}}/.gitignore"}. 27 | {chmod, "{{name}}/scripts/ensure-rebar3.sh", 8#755}. 28 | {chmod, "{{name}}/scripts/get-otp-vsn.sh", 8#755}. 29 | {template, "emqx-plugin-templates/README.md", "{{name}}/README.md"}. 30 | {template, "emqx-plugin-templates/rebar.config", "{{name}}/rebar.config"}. 31 | {template, "emqx-plugin-templates/src/emqx_plugin_template.app.src", "{{name}}/src/{{name}}.app.src"}. 32 | {template, "emqx-plugin-templates/src/emqx_plugin_template.erl", "{{name}}/src/{{name}}.erl"}. 33 | {template, "emqx-plugin-templates/src/emqx_plugin_template_app.erl", "{{name}}/src/{{name}}_app.erl"}. 34 | {template, "emqx-plugin-templates/src/emqx_plugin_template_cli.erl", "{{name}}/src/{{name}}_cli.erl"}. 35 | {template, "emqx-plugin-templates/src/emqx_plugin_template_sup.erl", "{{name}}/src/{{name}}_sup.erl"}. 36 | -------------------------------------------------------------------------------- /erlang_ls.config: -------------------------------------------------------------------------------- 1 | apps_dirs: 2 | - "src/*" 3 | deps_dirs: 4 | - "_build/default/lib/*" 5 | include_dirs: 6 | - "_build/default/lib/*/include" 7 | exclude_unused_includes: 8 | - "typerefl/include/types.hrl" 9 | - "logger.hrl" 10 | diagnostics: 11 | enabled: 12 | - bound_var_in_pattern 13 | - elvis 14 | - unused_includes 15 | - unused_macros 16 | - compiler 17 | disabled: 18 | - dialyzer 19 | - crossref 20 | lenses: 21 | disabled: 22 | # - show-behaviour-usages 23 | # - ct-run-test 24 | - server-info 25 | enable: 26 | - show-behaviour-usages 27 | - ct-run-test 28 | macros: 29 | - name: EMQX_RELEASE_EDITION 30 | value: ce 31 | code_reload: 32 | node: emqx@127.0.0.1 33 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | {project_plugins, [ 2 | {erlfmt, "1.6.0"} 3 | ]}. 4 | 5 | {profiles, [ 6 | {test, [ 7 | {deps, [ 8 | {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.13.5"}}}, 9 | {hackney, "1.20.1"}, 10 | {jiffy, "1.1.2"} 11 | ]} 12 | ]} 13 | ]}. 14 | -------------------------------------------------------------------------------- /scripts/build-sample-plugin.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | # Default values 6 | TAG="" 7 | NAME="" 8 | OUTPUT_DIR="" 9 | WITH_AVSC=false 10 | 11 | # Parse command line arguments 12 | while [[ $# -gt 0 ]]; do 13 | case $1 in 14 | --tag) 15 | TAG="$2" 16 | shift 2 17 | ;; 18 | --name) 19 | NAME="$2" 20 | shift 2 21 | ;; 22 | --output-dir) 23 | OUTPUT_DIR="$2" 24 | shift 2 25 | ;; 26 | --with-avsc) 27 | WITH_AVSC=true 28 | shift 29 | ;; 30 | *) 31 | echo "Unknown argument: $1" 32 | exit 1 33 | ;; 34 | esac 35 | done 36 | 37 | if [ -z "$TAG" ]; then 38 | echo "Error: --tag argument is required" 39 | exit 1 40 | fi 41 | 42 | if [ -z "$NAME" ]; then 43 | echo "Error: --name argument is required" 44 | exit 1 45 | fi 46 | 47 | cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." 48 | 49 | echo "Installing rebar template" 50 | 51 | ./scripts/install-rebar-template.sh 52 | 53 | echo "Creating plugin" 54 | 55 | rm -rf "$NAME" 56 | ./rebar3 new emqx-plugin "$NAME" version="$TAG" 57 | 58 | mv "$NAME/priv/config.hocon.example" "$NAME/priv/config.hocon" 59 | 60 | if [ "$WITH_AVSC" = true ]; then 61 | mv "$NAME/priv/config_schema.avsc.enterprise.example" "$NAME/priv/config_schema.avsc" 62 | mv "$NAME/priv/config_i18n.json.example" "$NAME/priv/config_i18n.json" 63 | fi 64 | 65 | echo "Building plugin" 66 | export BUILD_WITHOUT_QUIC=1 67 | make -C "$NAME" rel 68 | 69 | echo "Copying plugin to $OUTPUT_DIR" 70 | if [ -n "$OUTPUT_DIR" ]; then 71 | mkdir -p "$OUTPUT_DIR" 72 | cp "$NAME"/_build/default/emqx_plugrel/*.tar.gz "$OUTPUT_DIR" 73 | fi 74 | 75 | echo "Cleaning up" 76 | rm -rf "$NAME" 77 | -------------------------------------------------------------------------------- /scripts/ensure-rebar3.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | [ "${DEBUG:-0}" -eq 1 ] && set -x 6 | 7 | OTP_VSN="${OTP_VSN:-$(./scripts/get-otp-vsn.sh)}" 8 | case ${OTP_VSN} in 9 | 25*) 10 | VERSION="3.19.0-emqx-9" 11 | ;; 12 | 26*) 13 | VERSION="3.20.0-emqx-1" 14 | ;; 15 | 27*) 16 | VERSION="3.24.0-emqx-1" 17 | ;; 18 | *) 19 | echo "Unsupported Erlang/OTP version $OTP_VSN" 20 | exit 1 21 | ;; 22 | esac 23 | 24 | # ensure dir 25 | cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." 26 | 27 | DOWNLOAD_URL='https://github.com/emqx/rebar3/releases/download' 28 | 29 | download() { 30 | echo "downloading rebar3 ${VERSION}" 31 | curl -f -L "${DOWNLOAD_URL}/${VERSION}/rebar3" -o ./rebar3 32 | } 33 | 34 | # get the version number from the second line of the escript 35 | # because command `rebar3 -v` tries to load rebar.config 36 | # which is slow and may print some logs 37 | version() { 38 | head -n 2 ./rebar3 | tail -n 1 | tr ' ' '\n' | grep -E '^.+-emqx-.+' 39 | } 40 | 41 | if [ -f 'rebar3' ] && [ "$(version)" = "$VERSION" ]; then 42 | exit 0 43 | fi 44 | 45 | download 46 | chmod +x ./rebar3 47 | -------------------------------------------------------------------------------- /scripts/format-template-code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | if [ "$#" -gt 1 ]; then 6 | echo "Usage: $0 [--check]" 7 | exit 1 8 | fi 9 | 10 | check="false" 11 | if [ "$#" -eq 1 ]; then 12 | if [ "$1" == "--check" ]; then 13 | check="true" 14 | else 15 | echo "Usage: $0 [--check]" 16 | exit 1 17 | fi 18 | fi 19 | 20 | tmp_dir=$(mktemp -d) 21 | rebar="./rebar3" 22 | 23 | function mask_template() { 24 | local file=$1 25 | sed -i 's/{{=@@ @@=}}/%% {{=@@ @@=}}/g' $file 26 | sed -i 's/@@\(\w\+\)@@/xxxx_\1_xxxx/g' $file 27 | } 28 | 29 | function unmask_template() { 30 | local file=$1 31 | sed -i 's/%% {{=@@ @@=}}/{{=@@ @@=}}/g' $file 32 | sed -i 's/xxxx_\(\w\+\)_xxxx/@@\1@@/g' $file 33 | } 34 | 35 | function format() { 36 | local file=$1 37 | if [ "$check" == "false" ]; then 38 | "$rebar" fmt -w --verbose $file 39 | else 40 | "$rebar" fmt -c --verbose $file 41 | fi 42 | } 43 | 44 | function handle_file() { 45 | local file=$1 46 | local tmp_file=$tmp_dir/$(basename $file) 47 | 48 | cp $file $tmp_file 49 | mask_template $tmp_file 50 | format $tmp_file 51 | unmask_template $tmp_file 52 | 53 | mv $tmp_file $file 54 | } 55 | 56 | cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." 57 | 58 | for file in $(ls emqx-plugin-templates/src/*.{erl,app.src}); do 59 | echo "Handling $file" 60 | handle_file $file 61 | done 62 | 63 | rm -rf $tmp_dir 64 | -------------------------------------------------------------------------------- /scripts/get-otp-vsn.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | erl -noshell -eval '{ok, Version} = file:read_file(filename:join([code:root_dir(), "releases", erlang:system_info(otp_release), "OTP_VERSION"])), io:fwrite(Version), halt().' 6 | -------------------------------------------------------------------------------- /scripts/install-rebar-template.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # This script does not handle REBAR_CACHE_DIR environment variable 4 | # because its support is buggy in rebar3 5 | # https://github.com/erlang/rebar3/issues/2762 6 | 7 | cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." 8 | 9 | TEMPLATE_DIR="$HOME/.config/rebar3/templates/emqx-plugin-template" 10 | 11 | rm -rf "$TEMPLATE_DIR" 12 | mkdir -p "$TEMPLATE_DIR" 13 | 14 | cp -r emqx-plugin-templates "$TEMPLATE_DIR/emqx-plugin-templates" 15 | cp -r emqx-plugin.template "$TEMPLATE_DIR/emqx-plugin.template" 16 | 17 | -------------------------------------------------------------------------------- /src/emqx_pt.app.src: -------------------------------------------------------------------------------- 1 | {application, emqx_pt, [ 2 | {description, "EMQX Plugin Template"}, 3 | {vsn, "1.0.0"}, 4 | {modules, []}, 5 | {registered, []}, 6 | {applications, [kernel, stdlib]}, 7 | {env, []}, 8 | {licenses, ["Apache 2.0"]}, 9 | {maintainers, ["EMQX "]} 10 | ]}. 11 | -------------------------------------------------------------------------------- /test/emqx_pt_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(emqx_pt_SUITE). 2 | 3 | -include_lib("eunit/include/eunit.hrl"). 4 | 5 | -compile(nowarn_export_all). 6 | -compile(export_all). 7 | 8 | -include_lib("eunit/include/eunit.hrl"). 9 | -include_lib("common_test/include/ct.hrl"). 10 | 11 | %%-------------------------------------------------------------------- 12 | %% CT Setup 13 | %%-------------------------------------------------------------------- 14 | 15 | all() -> 16 | [ 17 | {group, avsc}, 18 | {group, no_avsc} 19 | ]. 20 | 21 | groups() -> 22 | [ 23 | {avsc, [], [t_authentication_smoke, t_authorization_smoke]}, 24 | {no_avsc, [], [t_authentication_smoke, t_authorization_smoke]} 25 | ]. 26 | 27 | init_per_suite(Config) -> 28 | ok = emqx_pt_test_helpers:start(), 29 | ok = emqx_pt_test_api_helpers:delete_all_plugins(), 30 | Config. 31 | 32 | end_per_suite(Config) -> 33 | ok = emqx_pt_test_helpers:stop(), 34 | Config. 35 | 36 | init_per_group(avsc, Config) -> 37 | PluginName = <<"my_emqx_plugin_avsc">>, 38 | PluginNameVsn = <<(PluginName)/binary, "-1.0.0">>, 39 | [{plugin_name_vsn, PluginNameVsn}, {plugin_name, PluginName} | Config]; 40 | init_per_group(no_avsc, Config) -> 41 | PluginName = <<"my_emqx_plugin">>, 42 | PluginNameVsn = <<(PluginName)/binary, "-1.0.0">>, 43 | [{plugin_name_vsn, PluginNameVsn}, {plugin_name, PluginName} | Config]. 44 | 45 | end_per_group(_Group, _Config) -> 46 | ok. 47 | 48 | init_per_testcase(_Case, Config) -> 49 | PluginNameVsn = ?config(plugin_name_vsn, Config), 50 | %% install plugin 51 | ok = emqx_pt_test_helpers:allow_plugin_install(PluginNameVsn), 52 | Filename = filename:join([ 53 | code:lib_dir(emqx_pt), "test", "assets", <> 54 | ]), 55 | ct:pal("Uploading plugin ~s~n", [Filename]), 56 | ok = emqx_pt_test_api_helpers:upload_plugin(Filename), 57 | ok = emqx_pt_test_api_helpers:start_plugin(PluginNameVsn), 58 | Config. 59 | 60 | end_per_testcase(_Case, Config) -> 61 | PluginNameVsn = ?config(plugin_name_vsn, Config), 62 | ok = emqx_pt_test_api_helpers:configure_plugin(PluginNameVsn, default_config()), 63 | ok = emqx_pt_test_api_helpers:delete_all_plugins(), 64 | ok. 65 | 66 | %%-------------------------------------------------------------------- 67 | %% Test cases 68 | %%-------------------------------------------------------------------- 69 | 70 | t_authentication_smoke(Config) -> 71 | erlang:process_flag(trap_exit, true), 72 | PluginNameVsn = ?config(plugin_name_vsn, Config), 73 | PluginName = ?config(plugin_name, Config), 74 | %% Check authentication hook 75 | 76 | %% Connect with valid clientid 77 | ValidClientId = <<"valid_client_id">>, 78 | {ok, Pid0} = emqtt:start_link(connect_opts([{clientid, ValidClientId}])), 79 | {ok, _} = emqtt:connect(Pid0), 80 | ok = emqtt:disconnect(Pid0), 81 | 82 | %% Connect with invalid clientid, we should fail 83 | InvalidClientId = <<"invalid#clientid">>, 84 | {ok, Pid1} = emqtt:start_link(connect_opts([{clientid, InvalidClientId}])), 85 | {error, {malformed_username_or_password, undefined}} = emqtt:connect(Pid1), 86 | 87 | %% Verify that the config got by CLI is correct 88 | PluginConfigJson0 = emqx_pt_test_helpers:emqx_ctl([PluginName, "get-config"]), 89 | PluginConfig0 = jiffy:decode(PluginConfigJson0, [return_maps]), 90 | #{<<"client_regex">> := <<"^[A-Za-z0-9_]+$">>} = PluginConfig0, 91 | 92 | %% Update the config to allow the invalid clientid 93 | NewPluginConfig0 = config(#{client_regex => <<"^[A-Za-z0-9_#]+$">>}), 94 | emqx_pt_test_api_helpers:configure_plugin(PluginNameVsn, NewPluginConfig0), 95 | 96 | %% Connect with invalid clientid again, no we should not fail 97 | {ok, Pid2} = emqtt:start_link(connect_opts([{clientid, InvalidClientId}])), 98 | {ok, _} = emqtt:connect(Pid2), 99 | ok = emqtt:disconnect(Pid2), 100 | 101 | %% Verify that the config got by CLI is correct 102 | PluginConfigJson1 = emqx_pt_test_helpers:emqx_ctl([PluginName, "get-config"]), 103 | PluginConfig1 = jiffy:decode(PluginConfigJson1, [return_maps]), 104 | #{<<"client_regex">> := <<"^[A-Za-z0-9_#]+$">>} = PluginConfig1. 105 | 106 | t_authorization_smoke(_Config) -> 107 | {ok, Pid} = emqtt:start_link(connect_opts([{clientid, <<"client_id">>}])), 108 | {ok, _} = emqtt:connect(Pid), 109 | 110 | %% Verify that hook effectively forbids subscribing to arbitrary topics 111 | %% Fails 112 | {ok, _, [128]} = emqtt:subscribe(Pid, <<"test/topic">>, 1), 113 | %% Succeeds 114 | {ok, _, [1]} = emqtt:subscribe(Pid, <<"room/client_id">>, 1), 115 | 116 | %% Cleanup 117 | ok = emqtt:disconnect(Pid). 118 | 119 | receive_messages() -> 120 | receive 121 | {publish, #{payload := Payload}} -> 122 | [binary_to_integer(Payload) | receive_messages()] 123 | after 500 -> 124 | [] 125 | end. 126 | 127 | %%-------------------------------------------------------------------- 128 | %% Internal functions 129 | %%-------------------------------------------------------------------- 130 | 131 | connect_opts(Opts) -> 132 | Opts ++ [{host, "127.0.0.1"}, {port, 1883}]. 133 | 134 | config(Overrides) -> 135 | maps:merge(default_config(), Overrides). 136 | 137 | default_config() -> 138 | #{ 139 | client_regex => <<"^[A-Za-z0-9_]+$">>, 140 | hostname => <<"localhost">>, 141 | port => 3306, 142 | connectionOptions => [ 143 | #{ 144 | optionName => <<"autoReconnect">>, 145 | optionType => <<"string">>, 146 | optionValue => <<"true">> 147 | } 148 | ], 149 | auth => #{ 150 | username => <<"admin">>, 151 | password => #{ 152 | string => <<"Public123">> 153 | } 154 | } 155 | }. 156 | -------------------------------------------------------------------------------- /test/emqx_pt_test_api_helpers.erl: -------------------------------------------------------------------------------- 1 | -module(emqx_pt_test_api_helpers). 2 | 3 | %% Plugin API 4 | -export([ 5 | upload_plugin/1, 6 | list_plugins/0, 7 | get_plugin/1, 8 | start_plugin/1, 9 | stop_plugin/1, 10 | delete_plugin/1, 11 | delete_all_plugins/0, 12 | configure_plugin/2 13 | ]). 14 | 15 | %%-------------------------------------------------------------------- 16 | %% Plugin API 17 | %%-------------------------------------------------------------------- 18 | 19 | upload_plugin(Filename) -> 20 | Parts = [ 21 | {file, Filename, <<"plugin">>, []} 22 | ], 23 | case 24 | emqx_pt_test_helpers:api_post_raw( 25 | {plugins, install}, 26 | [], 27 | {multipart, Parts} 28 | ) 29 | of 30 | {ok, _} -> 31 | ok; 32 | {error, Error} -> 33 | error(Error) 34 | end. 35 | 36 | list_plugins() -> 37 | case emqx_pt_test_helpers:api_get(plugins) of 38 | {ok, Plugins} when is_list(Plugins) -> 39 | Plugins; 40 | {error, Error} -> 41 | error(Error) 42 | end. 43 | 44 | get_plugin(PluginId) -> 45 | case emqx_pt_test_helpers:api_get({plugins, PluginId}) of 46 | {ok, Plugin} -> 47 | Plugin; 48 | {error, Error} -> 49 | error(Error) 50 | end. 51 | 52 | plugin_id(#{<<"name">> := Name, <<"rel_vsn">> := RelVsn}) -> 53 | <>. 54 | 55 | start_plugin(PluginId) -> 56 | case emqx_pt_test_helpers:api_put_raw({plugins, PluginId, start}, <<>>) of 57 | {ok, _} -> 58 | ok; 59 | {error, Error} -> 60 | error(Error) 61 | end. 62 | 63 | stop_plugin(PluginId) -> 64 | case emqx_pt_test_helpers:api_put_raw({plugins, PluginId, stop}, <<>>) of 65 | {ok, _} -> 66 | ok; 67 | {error, Error} -> 68 | error(Error) 69 | end. 70 | 71 | delete_plugin(PluginId) -> 72 | emqx_pt_test_helpers:api_delete({plugins, PluginId}). 73 | 74 | delete_all_plugins() -> 75 | Plugins = list_plugins(), 76 | lists:foreach( 77 | fun(Plugin) -> 78 | ok = stop_plugin(plugin_id(Plugin)), 79 | ok = delete_plugin(plugin_id(Plugin)) 80 | end, 81 | Plugins 82 | ). 83 | 84 | configure_plugin(PluginId, Config) -> 85 | case emqx_pt_test_helpers:api_put({plugins, PluginId, config}, Config) of 86 | ok -> 87 | ok; 88 | {error, Error} -> 89 | error(Error) 90 | end. 91 | -------------------------------------------------------------------------------- /test/emqx_pt_test_helpers.erl: -------------------------------------------------------------------------------- 1 | -module(emqx_pt_test_helpers). 2 | 3 | -compile(nowarn_export_all). 4 | -compile(export_all). 5 | 6 | start() -> 7 | {ok, _} = application:ensure_all_started(hackney), 8 | ok. 9 | 10 | stop() -> 11 | ok. 12 | 13 | allow_plugin_install(NameVsn) -> 14 | _ = emqx_ctl(["plugins", "allow", binary_to_list(NameVsn)]), 15 | timer:sleep(1000), 16 | ok. 17 | 18 | emqx_ctl(CtlCommand) -> 19 | DockerComposeFile = 20 | case os:getenv("DOCKER_COMPOSE_FILE", ".ci/docker-compose.yml") of 21 | false -> 22 | error("DOCKER_COMPOSE_FILE is not set"); 23 | File -> 24 | File 25 | end, 26 | Args = 27 | [ 28 | "compose", 29 | "-f", 30 | DockerComposeFile, 31 | "exec", 32 | "emqx", 33 | "/opt/emqx/bin/emqx", 34 | "ctl" 35 | ] ++ CtlCommand, 36 | exec_cmd("docker", Args). 37 | 38 | api_get(Path) -> 39 | Result = make_request({get, Path}), 40 | handle_result(Result). 41 | 42 | api_get_raw(Path) -> 43 | ct:pal("GET ~s~n~n", [api_url(Path)]), 44 | Result = hackney:request(get, api_url(Path), []), 45 | handle_result_raw(Result). 46 | 47 | api_post(Path, Body) -> 48 | Result = make_request({post, Path, Body}), 49 | handle_result(Result). 50 | 51 | api_post_raw(Path, Headers0, Body) -> 52 | HeadersS = [io_lib:format("~s: ~s~n", [Key, Value]) || {Key, Value} <- Headers0], 53 | ct:pal("POST ~s~n~s~n~n...", [api_url(Path), iolist_to_binary(HeadersS)]), 54 | Headers = [auth_header() | Headers0], 55 | Result = hackney:request(post, api_url(Path), Headers, Body), 56 | handle_result_raw(Result). 57 | 58 | api_put(Path, Body) -> 59 | Result = make_request({put, Path, Body}), 60 | handle_result(Result). 61 | 62 | api_put_raw(Path, Body) -> 63 | Result = hackney:request(put, api_url(Path), [auth_header()], Body), 64 | handle_result_raw(Result). 65 | 66 | api_delete(Path) -> 67 | Result = make_request({delete, Path}), 68 | handle_result(Result). 69 | 70 | make_request({get, Path}) -> 71 | ct:pal("GET ~s~n~n", [api_url(Path)]), 72 | hackney:request(get, api_url(Path), headers()); 73 | make_request({post, Path, Body}) -> 74 | ct:pal("POST ~s~n~n~s~n~n", [api_url(Path), encode_json(Body)]), 75 | hackney:request(post, api_url(Path), headers(), encode_json(Body)); 76 | make_request({put, Path, Body}) -> 77 | ct:pal("PUT ~s~n~n~s~n~n", [api_url(Path), encode_json(Body)]), 78 | hackney:request(put, api_url(Path), headers(), encode_json(Body)); 79 | make_request({delete, Path}) -> 80 | ct:pal("DELETE ~s~n~n", [api_url(Path)]), 81 | hackney:request(delete, api_url(Path), headers()). 82 | 83 | handle_result({ok, Code, _Headers, ClientRef}) when Code >= 200 andalso Code < 300 -> 84 | {ok, Json} = hackney:body(ClientRef), 85 | case Json of 86 | <<>> -> 87 | ok; 88 | _ -> 89 | {ok, decode_json(Json)} 90 | end; 91 | handle_result({ok, Code, _Headers, ClientRef}) -> 92 | {ok, Json} = hackney:body(ClientRef), 93 | {error, {http, {Code, Json}}}; 94 | handle_result({error, Reason}) -> 95 | {error, Reason}. 96 | 97 | handle_result_raw({ok, Code, _Headers, ClientRef}) when Code >= 200 andalso Code < 300 -> 98 | hackney:body(ClientRef); 99 | handle_result_raw({ok, Code, _Headers, ClientRef}) -> 100 | {ok, Body} = hackney:body(ClientRef), 101 | ct:pal("Response body:~n~s~n", [Body]), 102 | {error, {http_status, Code}}; 103 | handle_result_raw({error, Reason}) -> 104 | {error, Reason}. 105 | 106 | api_url(Path) when is_binary(Path) -> 107 | <<(api_url_base())/binary, Path/binary>>; 108 | api_url(Path) when is_list(Path) -> 109 | api_url(iolist_to_binary(Path)); 110 | api_url(Path) when is_atom(Path) -> 111 | api_url(atom_to_binary(Path, utf8)); 112 | api_url(Path0) when is_tuple(Path0) -> 113 | Path1 = [bin(Segment) || Segment <- tuple_to_list(Path0)], 114 | Path = lists:join("/", Path1), 115 | api_url(iolist_to_binary(Path)). 116 | 117 | bin(A) when is_atom(A) -> 118 | atom_to_binary(A, utf8); 119 | bin(B) when is_binary(B) -> 120 | B; 121 | bin(I) when is_integer(I) -> 122 | integer_to_binary(I); 123 | bin(L) when is_list(L) -> 124 | iolist_to_binary(L). 125 | 126 | api_host() -> 127 | <<"localhost">>. 128 | 129 | api_url_base() -> 130 | <<"http://", (api_host())/binary, ":18083/api/v5/">>. 131 | 132 | headers() -> 133 | [auth_header(), {<<"Content-Type">>, <<"application/json">>}]. 134 | 135 | auth_header() -> 136 | {<<"Authorization">>, <<"Basic ", (api_auth())/binary>>}. 137 | 138 | api_auth() -> 139 | base64:encode(<<"key:secret">>). 140 | 141 | decode_json(Body) -> 142 | jiffy:decode(Body, [return_maps]). 143 | 144 | encode_json(Data) -> 145 | jiffy:encode(Data). 146 | 147 | exec_cmd(Executable, Args) -> 148 | Port = open_port( 149 | {spawn_executable, os:find_executable(Executable)}, 150 | [ 151 | {args, args_to_strings(Args)}, 152 | binary, 153 | stderr_to_stdout, 154 | exit_status, 155 | eof 156 | ] 157 | ), 158 | collect_output(Port, []). 159 | 160 | collect_output(Port, Acc) -> 161 | receive 162 | {Port, {data, Data}} -> 163 | collect_output(Port, [Data | Acc]); 164 | {Port, eof} -> 165 | iolist_to_binary(lists:reverse(Acc)) 166 | after 30_000 -> 167 | error(command_timeout) 168 | end. 169 | 170 | args_to_strings(Args) -> 171 | [binary_to_list(iolist_to_binary(Arg)) || Arg <- Args]. 172 | --------------------------------------------------------------------------------