├── .github └── workflows │ ├── codeql-analysis.yml │ └── go.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── NOTICE ├── README.md ├── apidoc └── openapi-spec.yml ├── build.sh ├── catalog ├── catalog.go ├── controller.go ├── controller_test.go ├── errors.go ├── events.go ├── http.go ├── ldbstorage.go └── main_test.go ├── config.go ├── discovery.go ├── discovery_test.go ├── go.mod ├── go.sum ├── init.go ├── main.go ├── notification ├── controller.go ├── ldb_eventqueue.go ├── notification.go └── sse.go ├── router.go ├── sample_conf └── thing-directory.json ├── snap └── snapcraft.yaml └── wot ├── discovery.go ├── discovery_schema.json ├── error.go ├── thing_description.go ├── validation.go ├── validation_test.go └── wot_td_schema.json /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '31 20 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'go' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: CICD 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - '*.md' 7 | 8 | jobs: 9 | 10 | unit-test: 11 | name: Run unit tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Set up Go 1.x 15 | if: success() 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: ^1.14 19 | id: go 20 | 21 | - name: Check out code 22 | if: success() 23 | uses: actions/checkout@v2 24 | 25 | - name: Run tests 26 | if: success() 27 | run: go test -v ./... 28 | 29 | component-test: 30 | name: Run component tests 31 | runs-on: ubuntu-latest 32 | needs: unit-test 33 | steps: 34 | - name: Set up Go 1.x 35 | if: success() 36 | uses: actions/setup-go@v2 37 | with: 38 | go-version: ^1.14 39 | id: go 40 | 41 | - name: Check out code 42 | if: success() 43 | uses: actions/checkout@v2 44 | 45 | - name: Download validation files 46 | if: success() 47 | env: 48 | TD_VALIDATION_JSONSCHEMAS: "conf/wot_td_schema.json,conf/wot_discovery_schema.json" 49 | run: | 50 | curl https://raw.githubusercontent.com/w3c/wot-thing-description/REC1.0/validation/td-json-schema-validation.json --create-dirs -o conf/wot_td_schema.json 51 | curl https://raw.githubusercontent.com/w3c/wot-discovery/main/validation/td-discovery-extensions-json-schema.json --create-dirs -o conf/wot_discovery_schema.json 52 | 53 | - name: Checkout wot-discovery-testing 54 | uses: actions/checkout@v2 55 | with: 56 | repository: farshidtz/wot-discovery-testing 57 | path: wot-discovery-testing 58 | 59 | - name: Run tests 60 | if: success() 61 | env: 62 | TD_VALIDATION_JSONSCHEMAS: "conf/wot_td_schema.json,conf/wot_discovery_schema.json" 63 | run: | 64 | (go run . --conf sample_conf/thing-directory.json && echo) & 65 | sleep 10 66 | cd wot-discovery-testing/directory 67 | go test --server=http://localhost:8081 68 | 69 | - name: Export test report as artifact 70 | if: success() 71 | uses: actions/upload-artifact@v2 72 | with: 73 | name: test-report 74 | path: wot-discovery-testing/directory/report.csv 75 | 76 | 77 | build: 78 | name: Build and upload snapshots 79 | runs-on: ubuntu-latest 80 | needs: component-test 81 | steps: 82 | 83 | - name: Set up Go 1.x 84 | if: success() 85 | uses: actions/setup-go@v2 86 | with: 87 | go-version: ^1.14 88 | id: go 89 | 90 | - name: Check out code 91 | if: success() 92 | uses: actions/checkout@v2 93 | 94 | - name: Prepare Variables 95 | id: prepare 96 | run: | 97 | echo ::set-output name=version::${GITHUB_REF##*/} 98 | 99 | - name: Cross Compile go 100 | if: success() 101 | run: | 102 | ./build.sh 103 | mkdir -p output/bin output/conf 104 | cp bin/* output/bin 105 | cp sample_conf/* wot/wot_td_schema.json output/conf 106 | env: 107 | NAME: thing-directory 108 | VERSION: ${{ steps.prepare.outputs.version }} 109 | BUILDNUM: ${{github.run_number}} 110 | 111 | - name: Upload snapshots 112 | if: success() 113 | uses: actions/upload-artifact@v2 114 | with: 115 | name: snapshots 116 | path: output/ 117 | 118 | package-and-release: 119 | name: Upload release assets 120 | if: github.ref != 'refs/heads/main' && startsWith(github.ref, 'refs/tags/') 121 | runs-on: ubuntu-latest 122 | needs: build 123 | 124 | steps: 125 | 126 | - name: Prepare Variables 127 | id: prepare 128 | run: | 129 | echo ::set-output name=version::${GITHUB_REF##*/} 130 | 131 | - name: Download snapshot artifacts 132 | uses: actions/download-artifact@v2 133 | with: 134 | name: snapshots 135 | 136 | - name: Create Release 137 | if: success() 138 | id: release 139 | uses: actions/create-release@v1 140 | env: 141 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 142 | with: 143 | tag_name: ${{ steps.prepare.outputs.version }} 144 | release_name: ${{ steps.prepare.outputs.version }} 145 | body: "Docker image: `ghcr.io/tinyiot/thing-directory:${{ steps.prepare.outputs.version }}`" 146 | draft: false 147 | prerelease: true 148 | 149 | - name: Upload release asset windows-amd64.exe 150 | if: success() 151 | uses: actions/upload-release-asset@v1.0.1 152 | env: 153 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 154 | with: 155 | upload_url: ${{ steps.release.outputs.upload_url }} 156 | asset_path: bin/thing-directory-windows-amd64.exe 157 | asset_name: thing-directory-windows-amd64.exe 158 | asset_content_type: application/vnd.microsoft.portable-executable 159 | 160 | - name: Upload release asset darwin-amd64 161 | if: success() 162 | uses: actions/upload-release-asset@v1.0.1 163 | env: 164 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 165 | with: 166 | upload_url: ${{ steps.release.outputs.upload_url }} 167 | asset_path: bin/thing-directory-darwin-amd64 168 | asset_name: thing-directory-darwin-amd64 169 | asset_content_type: application/octet-stream 170 | 171 | - name: Upload release asset linux-amd64 172 | if: success() 173 | uses: actions/upload-release-asset@v1.0.1 174 | env: 175 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 176 | with: 177 | upload_url: ${{ steps.release.outputs.upload_url }} 178 | asset_path: bin/thing-directory-linux-amd64 179 | asset_name: thing-directory-linux-amd64 180 | asset_content_type: application/octet-stream 181 | 182 | - name: Upload release asset linux-arm64 183 | if: success() 184 | uses: actions/upload-release-asset@v1.0.1 185 | env: 186 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 187 | with: 188 | upload_url: ${{ steps.release.outputs.upload_url }} 189 | asset_path: bin/thing-directory-linux-arm64 190 | asset_name: thing-directory-linux-arm64 191 | asset_content_type: application/octet-stream 192 | 193 | - name: Upload release asset linux-arm 194 | if: success() 195 | uses: actions/upload-release-asset@v1.0.1 196 | env: 197 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 198 | with: 199 | upload_url: ${{ steps.release.outputs.upload_url }} 200 | asset_path: bin/thing-directory-linux-arm 201 | asset_name: thing-directory-linux-arm 202 | asset_content_type: application/octet-stream 203 | 204 | - name: Upload release asset sample_conf 205 | if: success() 206 | uses: actions/upload-release-asset@v1.0.1 207 | env: 208 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 209 | with: 210 | upload_url: ${{ steps.release.outputs.upload_url }} 211 | asset_path: conf/thing-directory.json 212 | asset_name: thing-directory.json 213 | asset_content_type: application/json 214 | 215 | docker: 216 | name: Build and push docker image 217 | runs-on: ubuntu-latest 218 | needs: component-test 219 | steps: 220 | 221 | - name: Prepare Variables 222 | id: prepare 223 | run: | 224 | echo ::set-output name=version::${GITHUB_REF##*/} 225 | 226 | - name: Check out code 227 | if: success() 228 | uses: actions/checkout@v2 229 | 230 | - name: Docker login 231 | if: success() 232 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u USERNAME --password-stdin 233 | 234 | - name: Build image 235 | if: success() 236 | run: docker build -t ghcr.io/tinyiot/thing-directory --build-arg version="${{ steps.prepare.outputs.version }}" --build-arg buildnum="${{github.run_number}}" . 237 | 238 | - name: Push latest docker image 239 | if: success() && github.ref == 'refs/heads/main' 240 | run: docker push ghcr.io/tinyiot/thing-directory:latest 241 | 242 | - name: Push tagged docker image 243 | if: success() && github.ref != 'refs/heads/main' && startsWith(github.ref, 'refs/tags/') 244 | run: | 245 | docker tag ghcr.io/tinyiot/thing-directory ghcr.io/tinyiot/thing-directory:${{ steps.prepare.outputs.version }} 246 | docker push ghcr.io/tinyiot/thing-directory:${{ steps.prepare.outputs.version }} 247 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | /pkg/ 3 | *.snap -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18-alpine as builder 2 | 3 | COPY . /home 4 | 5 | WORKDIR /home 6 | ENV CGO_ENABLED=0 7 | 8 | ARG version 9 | ARG buildnum 10 | RUN go build -v -ldflags "-X main.Version=$version -X main.BuildNumber=$buildnum" 11 | 12 | ########### 13 | FROM alpine:3 14 | 15 | RUN apk --no-cache add ca-certificates 16 | 17 | ARG version 18 | ARG buildnum 19 | LABEL NAME="TinyIoT Thing Directory" 20 | LABEL VERSION=${version} 21 | LABEL BUILD=${buildnum} 22 | 23 | WORKDIR /home 24 | COPY --from=builder /home/thing-directory . 25 | COPY sample_conf/thing-directory.json /home/conf/ 26 | COPY wot/wot_td_schema.json /home/conf/ 27 | 28 | ENV TD_STORAGE_DSN=/data 29 | 30 | VOLUME /data 31 | EXPOSE 8081 32 | 33 | ENTRYPOINT ["./thing-directory"] 34 | # Note: this loads the default config files from /home/conf/. Use --help to to learn about CLI arguments. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | TinyIoT Thing Directory 2 | Copyright 2021 The Contributors 3 | 4 | The list of project contributors are available at: 5 | https://github.com/TinyIoT/thing-directory/graphs/contributors 6 | 7 | This software is a fork of the LinkSmart Thing Directory (https://github.com/linksmart/thing-directory) 8 | developed by the Fraunhofer Institute for Applied Information Technology (https://fit.fraunhofer.de), 9 | a division of Fraunhofer Gesellschaft e. V. 10 | 11 | It includes the following dependencies developed by 12 | the corresponding developers and organisations: 13 | 14 | * github.com/antchfx/jsonquery by authors (MIT License) 15 | * github.com/bhmj/jsonslice by bhmj (MIT License) 16 | * github.com/urfave/negroni by Jeremy Saenz (MIT License) 17 | * github.com/evanphx/json-patch by Evan Phoenix (BSD 3-Clause License) 18 | * github.com/gorilla/mux by The Gorilla Authors (BSD 3-Clause License) 19 | * github.com/gorilla/context by The Gorilla Authors (BSD 3-Clause License) 20 | * github.com/grandcat/zeroconf by Oleksandr Lobunets and Stefan Smarzly (MIT License) 21 | * github.com/justinas/alice by Justinas Stankevicius (MIT License) 22 | * github.com/kelseyhightower/envconfig by Kelsey Hightower (MIT License) 23 | * github.com/linksmart/service-catalog by Fraunhofer Gesellschaft (Apache 2.0 License) 24 | * github.com/linksmart/go-sec by Fraunhofer Gesellschaft (Apache 2.0 License) 25 | * github.com/rs/cors by Olivier Poitrey (MIT License) 26 | * github.com/satori/go.uuid by Maxim Bublis (MIT License) 27 | * github.com/syndtr/goleveldb by Suryandaru Triandana (MIT License) 28 | * github.com/xeipuuv/gojsonschema by xeipuuv (Apache 2.0 License) 29 | 30 | 31 | Moreover, it includes portions of source code from the following developers and organizations: 32 | 33 | * https://gist.github.com/ismasan/3fb75381cd2deb6bfa9c by Ismael Celis (MIT License) 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TinyIoT Thing Directory 2 | This is an implementation of the [W3C Web of Things (WoT) Thing Description Directory (TDD)](https://w3c.github.io/wot-discovery/), a registry of [Thing Descriptions](https://www.w3.org/TR/wot-thing-description/). 3 | 4 | ## Getting Started 5 | Visit the following pages to get started: 6 | * [Deployment](../../wiki/Deployment): How to deploy the software, as Docker container, Debian package, or platform-specific binary distributions 7 | * [Configuration](../../wiki/Configuration): How to configure the server software with JSON files and environment variables 8 | * [API Documentation][1]: How to interact with the networking APIs 9 | 10 | **Further documentation are available in the [wiki](../../wiki)**. 11 | 12 | ## Features 13 | * Service Discovery 14 | * [DNS-SD registration](../../wiki/Discovery-with-DNS-SD) 15 | * RESTful API 16 | * [HTTP API][1] 17 | * Things API - TD creation, read, update (put/patch), deletion, and listing (pagination) 18 | * Search API - [JSONPath query language](../../wiki/Query-Language) 19 | * Events API 20 | * TD validation with JSON Schema(s) 21 | * Request [authentication](https://github.com/linksmart/go-sec/wiki/Authentication) and [authorization](https://github.com/linksmart/go-sec/wiki/Authorization) 22 | * JSON-LD response format 23 | * Persistent Storage 24 | * LevelDB 25 | * CI/CD ([Github Actions](https://github.com/tinyiot/thing-directory/actions?query=workflow:CICD)) 26 | * Automated testing 27 | * Automated builds and releases 28 | 29 | ## Development 30 | 31 | Clone this repo: 32 | ```bash 33 | git clone https://github.com/tinyiot/thing-directory.git 34 | cd thing-directory 35 | ``` 36 | 37 | Compile from source: 38 | ```bash 39 | go build 40 | ``` 41 | This will result in an executable named `thing-directory` (linux/macOS) or `thing-directory.exe` (windows). 42 | 43 | Get the CLI arguments help (linux/macOS): 44 | ```bash 45 | $ ./thing-directory -help 46 | Usage of ./thing-directory: 47 | -conf string 48 | Configuration file path (default "conf/thing-directory.json") 49 | -version 50 | Print the API version 51 | ``` 52 | 53 | Run (linux/macOS): 54 | ```bash 55 | $ ./thing-directory --conf=sample_conf/thing-directory.json 56 | ``` 57 | 58 | To build and run together: 59 | ```bash 60 | go run . --conf=sample_conf/thing-directory.json 61 | ``` 62 | 63 | Test all packages (add `-v` flag for verbose results): 64 | ```bash 65 | go test ./... 66 | ``` 67 | 68 | 69 | ## Contributing 70 | Contributions are welcome. 71 | 72 | Please fork, make your changes, and submit a pull request. For major changes, please open an issue first and discuss it with the other authors. 73 | 74 | 75 | [1]: https://petstore.swagger.io?url=https://raw.githubusercontent.com/tinyiot/thing-directory/master/apidoc/openapi-spec.yml 76 | -------------------------------------------------------------------------------- /apidoc/openapi-spec.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | 3 | #servers: 4 | # - url: http://localhost:8081 5 | 6 | info: 7 | version: "1.0.0-beta.26" 8 | title: TinyIoT Thing Directory 9 | description: API documentation of the [TinyIoT Thing Directory](https://github.com/tinyiot/thing-directory) 10 | license: 11 | name: Apache 2.0 12 | url: https://github.com/tinyiot/thing-directory/blob/master/LICENSE 13 | 14 | tags: 15 | - name: things 16 | description: Things API 17 | - name: search 18 | description: Search API 19 | - name: events 20 | description: Notification API 21 | 22 | paths: 23 | /things: 24 | get: 25 | tags: 26 | - things 27 | summary: Retrieves the list of Thing Descriptions 28 | parameters: 29 | - name: offset 30 | in: query 31 | description: Offset number in the pagination 32 | required: false 33 | schema: 34 | type: number 35 | format: integer 36 | default: 0 37 | - name: limit 38 | in: query 39 | description: Number of entries per page. When not set, all entries are returned incrementally. 40 | required: false 41 | schema: 42 | type: number 43 | format: integer 44 | responses: 45 | '200': 46 | description: Successful response 47 | content: 48 | application/ld+json: 49 | schema: 50 | type: array 51 | items: 52 | $ref: '#/components/schemas/ThingDescription' 53 | '400': 54 | $ref: '#/components/responses/RespBadRequest' 55 | '401': 56 | $ref: '#/components/responses/RespUnauthorized' 57 | '403': 58 | $ref: '#/components/responses/RespForbidden' 59 | '500': 60 | $ref: '#/components/responses/RespInternalServerError' 61 | post: 62 | tags: 63 | - things 64 | summary: Creates new Thing Description with system-generated ID 65 | description: | 66 | This is to create a TD and receive a unique system-generated `id` in response.
67 | The server rejects the request if there is an `id` in the body.
68 | For creating a TD with user-defined `id`, use the `PUT` method. 69 | responses: 70 | '201': 71 | description: Created successfully 72 | headers: 73 | Location: 74 | description: Path to the newly created Thing Description 75 | schema: 76 | type: string 77 | '400': 78 | $ref: '#/components/responses/RespValidationBadRequest' 79 | '401': 80 | $ref: '#/components/responses/RespUnauthorized' 81 | '403': 82 | $ref: '#/components/responses/RespForbidden' 83 | '500': 84 | $ref: '#/components/responses/RespInternalServerError' 85 | requestBody: 86 | content: 87 | application/td+json: 88 | schema: 89 | $ref: '#/components/schemas/ThingDescription' 90 | examples: 91 | ThingDescription: 92 | $ref: '#/components/examples/ThingDescriptionWithoutID' 93 | 94 | description: Thing Description to be created 95 | required: true 96 | /things/{id}: 97 | put: 98 | tags: 99 | - things 100 | summary: Creates a new Thing Description with the provided ID, or updates an existing one 101 | description: | 102 | The `id` in the path is the resource id and must match the one in Thing Description.
103 | For creating a TD without user-defined `id`, use the `POST` method. 104 | parameters: 105 | - name: id 106 | in: path 107 | description: ID of the Thing Description 108 | example: "urn:example:1234" 109 | required: true 110 | schema: 111 | type: string 112 | responses: 113 | '201': 114 | description: A new Thing Description is created 115 | '204': 116 | description: Thing Description updated successfully 117 | '400': 118 | $ref: '#/components/responses/RespValidationBadRequest' 119 | '401': 120 | $ref: '#/components/responses/RespUnauthorized' 121 | '403': 122 | $ref: '#/components/responses/RespForbidden' 123 | '409': 124 | $ref: '#/components/responses/RespConflict' 125 | '500': 126 | $ref: '#/components/responses/RespInternalServerError' 127 | requestBody: 128 | content: 129 | application/td+json: 130 | schema: 131 | $ref: '#/components/schemas/ThingDescription' 132 | examples: 133 | ThingDescription: 134 | $ref: '#/components/examples/ThingDescriptionWithID' 135 | description: The Thing Description object 136 | required: true 137 | patch: 138 | tags: 139 | - things 140 | summary: Patch a Thing Description 141 | description: The patch document must be based on RFC7396 JSON Merge Patch 142 | parameters: 143 | - name: id 144 | in: path 145 | description: ID of the Thing Description 146 | example: "urn:example:1234" 147 | required: true 148 | schema: 149 | type: string 150 | responses: 151 | '204': 152 | description: Thing Description patched successfully 153 | '400': 154 | $ref: '#/components/responses/RespValidationBadRequest' 155 | '401': 156 | $ref: '#/components/responses/RespUnauthorized' 157 | '403': 158 | $ref: '#/components/responses/RespForbidden' 159 | '409': 160 | $ref: '#/components/responses/RespConflict' 161 | '500': 162 | $ref: '#/components/responses/RespInternalServerError' 163 | requestBody: 164 | content: 165 | application/merge-patch+json: 166 | schema: 167 | type: object 168 | examples: 169 | ThingDescription: 170 | $ref: '#/components/examples/ThingDescriptionWithID' 171 | description: The Thing Description object 172 | required: true 173 | get: 174 | tags: 175 | - things 176 | summary: Retrieves a Thing Description 177 | parameters: 178 | - name: id 179 | in: path 180 | description: ID of the Thing Description 181 | example: "urn:example:1234" 182 | required: true 183 | schema: 184 | type: string 185 | responses: 186 | '200': 187 | description: Successful response 188 | content: 189 | application/td+json: 190 | schema: 191 | $ref: '#/components/schemas/ThingDescription' 192 | examples: 193 | response: 194 | $ref: '#/components/examples/ThingDescriptionWithID' 195 | '400': 196 | $ref: '#/components/responses/RespBadRequest' 197 | '401': 198 | $ref: '#/components/responses/RespUnauthorized' 199 | '403': 200 | $ref: '#/components/responses/RespForbidden' 201 | '404': 202 | $ref: '#/components/responses/RespNotfound' 203 | '500': 204 | $ref: '#/components/responses/RespInternalServerError' 205 | delete: 206 | tags: 207 | - things 208 | summary: Deletes the Thing Description 209 | parameters: 210 | - name: id 211 | in: path 212 | description: ID of the Thing Description 213 | required: true 214 | schema: 215 | type: string 216 | responses: 217 | '204': 218 | description: Successful response 219 | '401': 220 | $ref: '#/components/responses/RespUnauthorized' 221 | '403': 222 | $ref: '#/components/responses/RespForbidden' 223 | '404': 224 | $ref: '#/components/responses/RespNotfound' 225 | '500': 226 | $ref: '#/components/responses/RespInternalServerError' 227 | 228 | /search/jsonpath: 229 | get: 230 | tags: 231 | - search 232 | summary: Query TDs with JSONPath expression 233 | description: The query languages, described [here](https://github.com/tinyiot/thing-directory/wiki/Query-Language), can be used to filter results and select parts of Thing Descriptions. 234 | parameters: 235 | - name: query 236 | in: query 237 | description: JSONPath expression for fetching specific items. E.g. `$[?(@.title=='Kitchen Lamp')].properties` 238 | required: true 239 | schema: 240 | type: string 241 | # example: $[?(@.title=='Kitchen Lamp')].properties 242 | responses: 243 | '200': 244 | description: Successful response 245 | content: 246 | application/json: 247 | schema: 248 | type: array 249 | items: 250 | oneOf: 251 | - type: string 252 | - type: number 253 | - type: integer 254 | - type: boolean 255 | - type: array 256 | - type: object 257 | # examples: 258 | # ThingDescriptionList: 259 | # $ref: '#/components/examples/ThingDescriptionList' 260 | '400': 261 | $ref: '#/components/responses/RespBadRequest' 262 | '401': 263 | $ref: '#/components/responses/RespUnauthorized' 264 | '403': 265 | $ref: '#/components/responses/RespForbidden' 266 | '500': 267 | $ref: '#/components/responses/RespInternalServerError' 268 | 269 | /events: 270 | get: 271 | tags: 272 | - events 273 | summary: Subscribe to all events 274 | description: This API uses the [Server-Sent Events (SSE)](https://www.w3.org/TR/eventsource/) protocol. 275 | parameters: 276 | - name: diff 277 | in: query 278 | description: Include changed TD attributes inside events payload 279 | required: false 280 | schema: 281 | type: boolean 282 | responses: 283 | '200': 284 | $ref: '#/components/responses/RespEventStream' 285 | '400': 286 | $ref: '#/components/responses/RespBadRequest' 287 | '401': 288 | $ref: '#/components/responses/RespUnauthorized' 289 | '403': 290 | $ref: '#/components/responses/RespForbidden' 291 | '500': 292 | $ref: '#/components/responses/RespInternalServerError' 293 | /events/{type}: 294 | get: 295 | tags: 296 | - events 297 | summary: Subscribe to specific events 298 | description: This API uses the [Server-Sent Events (SSE)](https://www.w3.org/TR/eventsource/) protocol. 299 | parameters: 300 | - name: type 301 | in: path 302 | description: Event type 303 | required: true 304 | schema: 305 | type: string 306 | enum: 307 | - create 308 | - update 309 | - delete 310 | - name: diff 311 | in: query 312 | description: Include changed TD attributes inside events payload 313 | required: false 314 | schema: 315 | type: boolean 316 | responses: 317 | '200': 318 | $ref: '#/components/responses/RespEventStream' 319 | '400': 320 | $ref: '#/components/responses/RespBadRequest' 321 | '401': 322 | $ref: '#/components/responses/RespUnauthorized' 323 | '403': 324 | $ref: '#/components/responses/RespForbidden' 325 | '500': 326 | $ref: '#/components/responses/RespInternalServerError' 327 | 328 | security: 329 | - BasicAuth: [] 330 | - BearerAuth: [] 331 | 332 | components: 333 | securitySchemes: 334 | BasicAuth: 335 | type: http 336 | scheme: basic 337 | BearerAuth: 338 | type: http 339 | scheme: bearer 340 | bearerFormat: JWT 341 | 342 | responses: 343 | RespBadRequest: 344 | description: Bad Request 345 | content: 346 | application/json: 347 | schema: 348 | $ref: '#/components/schemas/ProblemDetails' 349 | RespValidationBadRequest: 350 | description: Bad Request (e.g. validation error) 351 | content: 352 | application/json: 353 | schema: 354 | oneOf: 355 | - $ref: '#/components/schemas/ProblemDetails' 356 | - $ref: '#/components/schemas/ValidationError' 357 | ValidationErrorResponse: 358 | description: Invalid Thing Description 359 | content: 360 | application/json: 361 | schema: 362 | $ref: '#/components/schemas/ValidationError' 363 | RespUnauthorized: 364 | description: Unauthorized 365 | content: 366 | application/json: 367 | schema: 368 | $ref: '#/components/schemas/ProblemDetails' 369 | RespForbidden: 370 | description: Forbidden 371 | content: 372 | application/json: 373 | schema: 374 | $ref: '#/components/schemas/ProblemDetails' 375 | RespNotfound: 376 | description: Not Found 377 | content: 378 | application/json: 379 | schema: 380 | $ref: '#/components/schemas/ProblemDetails' 381 | RespConflict: 382 | description: Conflict 383 | content: 384 | application/json: 385 | schema: 386 | $ref: '#/components/schemas/ProblemDetails' 387 | RespInternalServerError: 388 | description: Internal Server Error 389 | content: 390 | application/ld+json: 391 | schema: 392 | $ref: '#/components/schemas/ProblemDetails' 393 | RespEventStream: 394 | description: Events stream 395 | content: 396 | text/event-stream: 397 | schema: 398 | type: array 399 | format: chunked 400 | items: 401 | type: object 402 | format: text 403 | required: 404 | - id 405 | - event 406 | - data 407 | properties: 408 | id: 409 | type: integer 410 | description: event id 411 | event: 412 | type: string 413 | description: event type 414 | data: 415 | type: object 416 | format: json 417 | required: 418 | - id 419 | schemas: 420 | ProblemDetails: 421 | description: RFC7807 Problem Details (https://tools.ietf.org/html/rfc7807) 422 | properties: 423 | # type: 424 | # type: string 425 | # description: A URI reference that identifies the problem type. 426 | status: 427 | type: integer 428 | format: int32 429 | description: The HTTP status code. 430 | title: 431 | type: string 432 | description: A short, human-readable summary of the problem type. 433 | detail: 434 | type: string 435 | description: A human-readable explanation specific to this occurrence of the problem 436 | instance: 437 | type: string 438 | description: A URI reference that identifies the specific occurrence of the problem.\ 439 | ValidationError: 440 | description: Thing Description validation error 441 | allOf: 442 | - $ref: '#/components/schemas/ProblemDetails' 443 | - type: object 444 | properties: 445 | validationErrors: 446 | type: array 447 | items: 448 | type: object 449 | properties: 450 | field: 451 | type: string 452 | description: 453 | type: string 454 | 455 | ThingDescription: 456 | #type: object 457 | $ref: 'https://raw.githubusercontent.com/w3c/wot-thing-description/main/validation/td-json-schema-validation.json' 458 | 459 | ValidationResult: 460 | type: object 461 | properties: 462 | valid: 463 | type: boolean 464 | errors: 465 | type: array 466 | items: 467 | type: string 468 | 469 | examples: 470 | ThingDescriptionWithoutID: 471 | summary: Example Thing Description 472 | value: 473 | { 474 | "@context": "https://www.w3.org/2019/wot/td/v1", 475 | "title": "ExampleSensor", 476 | "properties": { 477 | "status": { 478 | "forms": [ 479 | { 480 | "op": ["readproperty"], 481 | "href": "https://example.com/status", 482 | "contentType": "text/html" 483 | } 484 | ] 485 | } 486 | }, 487 | "security": ["nosec_sc"], 488 | "securityDefinitions": {"nosec_sc":{"scheme":"nosec"} 489 | } 490 | } 491 | ThingDescriptionWithID: 492 | summary: Example Thing Description 493 | value: 494 | { 495 | "@context": "https://www.w3.org/2019/wot/td/v1", 496 | "id": "urn:example:1234", 497 | "title": "ExampleSensor", 498 | "properties": { 499 | "status": { 500 | "forms": [ 501 | { 502 | "op": ["readproperty"], 503 | "href": "https://example.com/status", 504 | "contentType": "text/html" 505 | } 506 | ] 507 | } 508 | }, 509 | "security": ["nosec_sc"], 510 | "securityDefinitions": {"nosec_sc":{"scheme":"nosec"} 511 | } 512 | } 513 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # EXAMPLES 4 | # Build for default platforms: 5 | # NAME=app ./go-build.sh 6 | # Build for specific platforms: 7 | # NAME=app PLATFORMS="linux/arm64 linux/arm" ./go-build.sh 8 | # Pass version and build number (ldflags): 9 | # NAME=app VERSION=v1.0.0 BUILDNUM=1 ./go-build.sh 10 | 11 | output_dir=bin 12 | 13 | export GO111MODULE=on 14 | export CGO_ENABLED=0 15 | 16 | if [[ -z "$NAME" ]]; then 17 | echo "usage: NAME=app sh go-build.sh" 18 | exit 1 19 | fi 20 | 21 | if [[ -z "$PLATFORMS" ]]; then 22 | PLATFORMS="windows/amd64 darwin/amd64 linux/amd64 linux/arm64 linux/arm" 23 | echo "Using default platforms: $PLATFORMS" 24 | fi 25 | 26 | if [[ -n "$VERSION" ]]; then 27 | echo "Version: $VERSION" 28 | fi 29 | 30 | if [[ -n "$BUILDNUM" ]]; then 31 | echo "Build Num: $BUILDNUM" 32 | fi 33 | 34 | for platform in $PLATFORMS 35 | do 36 | platform_split=(${platform//\// }) 37 | export GOOS=${platform_split[0]} 38 | export GOARCH=${platform_split[1]} 39 | output_name=$output_dir'/'$NAME'-'$GOOS'-'$GOARCH 40 | if [ $GOOS = "windows" ]; then 41 | output_name+='.exe' 42 | fi 43 | echo "Building $output_name" 44 | 45 | go build -ldflags "-X main.Version=$VERSION -X main.BuildNumber=$BUILDNUM" -o $output_name 46 | if [ $? -ne 0 ]; then 47 | echo "An error has occurred! Aborting the script execution..." 48 | exit 1 49 | fi 50 | done 51 | 52 | # Adapted from: 53 | # https://www.digitalocean.com/community/tutorials/how-to-build-go-executables-for-multiple-platforms-on-ubuntu-16-04 54 | # https://github.com/linksmart/ci-scripts/blob/master/go/go-build.sh 55 | -------------------------------------------------------------------------------- /catalog/catalog.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/tinyiot/thing-directory/wot" 8 | ) 9 | 10 | type ThingDescription = map[string]interface{} 11 | 12 | const ( 13 | // Storage backend types 14 | BackendMemory = "memory" 15 | BackendLevelDB = "leveldb" 16 | ) 17 | 18 | func validateThingDescription(td map[string]interface{}) ([]wot.ValidationError, error) { 19 | result, err := wot.ValidateTD(&td) 20 | if err != nil { 21 | return nil, fmt.Errorf("error validating with JSON Schemas: %s", err) 22 | } 23 | return result, nil 24 | } 25 | 26 | // Controller interface 27 | type CatalogController interface { 28 | add(d ThingDescription) (string, error) 29 | get(id string) (ThingDescription, error) 30 | update(id string, d ThingDescription) error 31 | patch(id string, d ThingDescription) error 32 | delete(id string) error 33 | listPaginate(offset, limit int) ([]ThingDescription, error) 34 | filterJSONPathBytes(query string) ([]byte, error) 35 | iterateBytes(ctx context.Context) <-chan []byte 36 | cleanExpired() 37 | Stop() 38 | AddSubscriber(listener EventListener) 39 | } 40 | 41 | // Storage interface 42 | type Storage interface { 43 | add(id string, td ThingDescription) error 44 | update(id string, td ThingDescription) error 45 | delete(id string) error 46 | get(id string) (ThingDescription, error) 47 | listPaginate(offset, limit int) ([]ThingDescription, error) 48 | listAllBytes() ([]byte, error) 49 | iterate() <-chan ThingDescription 50 | iterateBytes(ctx context.Context) <-chan []byte 51 | Close() 52 | } 53 | -------------------------------------------------------------------------------- /catalog/controller.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "runtime/debug" 9 | "strconv" 10 | "time" 11 | 12 | xpath "github.com/antchfx/jsonquery" 13 | jsonpath "github.com/bhmj/jsonslice" 14 | jsonpatch "github.com/evanphx/json-patch/v5" 15 | uuid "github.com/satori/go.uuid" 16 | "github.com/tinyiot/thing-directory/wot" 17 | ) 18 | 19 | const ( 20 | MaxLimit = 100 21 | ) 22 | 23 | var controllerExpiryCleanupInterval = 60 * time.Second // to be modified in unit tests 24 | 25 | type Controller struct { 26 | storage Storage 27 | listeners eventHandler 28 | } 29 | 30 | func NewController(storage Storage) (CatalogController, error) { 31 | c := Controller{ 32 | storage: storage, 33 | } 34 | 35 | go c.cleanExpired() 36 | 37 | return &c, nil 38 | } 39 | 40 | func (c *Controller) AddSubscriber(listener EventListener) { 41 | c.listeners = append(c.listeners, listener) 42 | } 43 | 44 | func (c *Controller) add(td ThingDescription) (string, error) { 45 | id, ok := td[wot.KeyThingID].(string) 46 | if !ok || id == "" { 47 | // System generated id 48 | id = c.newURN() 49 | td[wot.KeyThingID] = id 50 | } 51 | 52 | results, err := validateThingDescription(td) 53 | if err != nil { 54 | return "", err 55 | } 56 | if len(results) != 0 { 57 | return "", &ValidationError{results} 58 | } 59 | 60 | now := time.Now().UTC() 61 | tr := ThingRegistration(td) 62 | td[wot.KeyThingRegistration] = wot.ThingRegistration{ 63 | Created: &now, 64 | Modified: &now, 65 | Expires: computeExpiry(tr, now), 66 | TTL: ThingTTL(tr), 67 | } 68 | 69 | err = c.storage.add(id, td) 70 | if err != nil { 71 | return "", err 72 | } 73 | 74 | go c.listeners.created(td) 75 | 76 | return id, nil 77 | } 78 | 79 | func (c *Controller) get(id string) (ThingDescription, error) { 80 | td, err := c.storage.get(id) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | //tr := ThingRegistration(td) 86 | //now := time.Now() 87 | //tr.Retrieved = &now 88 | //td[wot.KeyThingRegistration] = tr 89 | 90 | return td, nil 91 | } 92 | 93 | func (c *Controller) update(id string, td ThingDescription) error { 94 | oldTD, err := c.storage.get(id) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | results, err := validateThingDescription(td) 100 | if err != nil { 101 | return err 102 | } 103 | if len(results) != 0 { 104 | return &ValidationError{ValidationErrors: results} 105 | } 106 | 107 | now := time.Now().UTC() 108 | oldTR := ThingRegistration(oldTD) 109 | tr := ThingRegistration(td) 110 | td[wot.KeyThingRegistration] = wot.ThingRegistration{ 111 | Created: oldTR.Created, 112 | Modified: &now, 113 | Expires: computeExpiry(tr, now), 114 | TTL: ThingTTL(tr), 115 | } 116 | 117 | err = c.storage.update(id, td) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | go c.listeners.updated(oldTD, td) 123 | 124 | return nil 125 | } 126 | 127 | // TODO: Improve patch by reducing the number of (de-)serializations 128 | func (c *Controller) patch(id string, td ThingDescription) error { 129 | oldTD, err := c.storage.get(id) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | // serialize to json for mergepatch input 135 | oldBytes, err := json.Marshal(oldTD) 136 | if err != nil { 137 | return err 138 | } 139 | patchBytes, err := json.Marshal(td) 140 | if err != nil { 141 | return err 142 | } 143 | //fmt.Printf("%s", patchBytes) 144 | 145 | newBytes, err := jsonpatch.MergePatch(oldBytes, patchBytes) 146 | if err != nil { 147 | return err 148 | } 149 | oldBytes, patchBytes = nil, nil 150 | 151 | td = ThingDescription{} 152 | err = json.Unmarshal(newBytes, &td) 153 | if err != nil { 154 | return err 155 | } 156 | 157 | results, err := validateThingDescription(td) 158 | if err != nil { 159 | return err 160 | } 161 | if len(results) != 0 { 162 | return &ValidationError{results} 163 | } 164 | 165 | //td[wot.KeyThingRegistrationModified] = time.Now().UTC() 166 | now := time.Now().UTC() 167 | oldTR := ThingRegistration(oldTD) 168 | tr := ThingRegistration(td) 169 | td[wot.KeyThingRegistration] = wot.ThingRegistration{ 170 | Created: oldTR.Created, 171 | Modified: &now, 172 | Expires: computeExpiry(tr, now), 173 | TTL: ThingTTL(tr), 174 | } 175 | 176 | err = c.storage.update(id, td) 177 | if err != nil { 178 | return err 179 | } 180 | 181 | go c.listeners.updated(oldTD, td) 182 | 183 | return nil 184 | } 185 | 186 | func (c *Controller) delete(id string) error { 187 | oldTD, err := c.storage.get(id) 188 | if err != nil { 189 | return err 190 | } 191 | 192 | err = c.storage.delete(id) 193 | if err != nil { 194 | return err 195 | } 196 | 197 | go c.listeners.deleted(oldTD) 198 | 199 | return nil 200 | } 201 | 202 | func (c *Controller) listPaginate(offset, limit int) ([]ThingDescription, error) { 203 | if offset < 0 || limit < 0 { 204 | return nil, fmt.Errorf("offset and limit must not be negative") 205 | } 206 | if limit > MaxLimit { 207 | return nil, fmt.Errorf("limit must be smaller than %d", MaxLimit) 208 | } 209 | 210 | tds, err := c.storage.listPaginate(offset, limit) 211 | if err != nil { 212 | return nil, err 213 | } 214 | 215 | return tds, nil 216 | } 217 | 218 | func (c *Controller) filterJSONPathBytes(query string) ([]byte, error) { 219 | // query all items 220 | b, err := c.storage.listAllBytes() 221 | if err != nil { 222 | return nil, err 223 | } 224 | 225 | // filter results with jsonpath 226 | b, err = jsonpath.Get(b, query) 227 | if err != nil { 228 | return nil, &BadRequestError{fmt.Sprintf("error evaluating jsonpath: %s", err)} 229 | } 230 | 231 | return b, nil 232 | } 233 | 234 | func (c *Controller) iterateBytes(ctx context.Context) <-chan []byte { 235 | return c.storage.iterateBytes(ctx) 236 | } 237 | 238 | // UTILITY FUNCTIONS 239 | 240 | func ThingRegistration(td ThingDescription) *wot.ThingRegistration { 241 | _, found := td[wot.KeyThingRegistration] 242 | if found && td[wot.KeyThingRegistration] != nil { 243 | if trMap, ok := td[wot.KeyThingRegistration].(map[string]interface{}); ok { 244 | var tr wot.ThingRegistration 245 | parsedTime := func(t string) *time.Time { 246 | parsed, err := time.Parse(time.RFC3339, t) 247 | if err != nil { 248 | panic(err) 249 | } 250 | return &parsed 251 | } 252 | 253 | if created, ok := trMap[wot.KeyThingRegistrationCreated].(string); ok { 254 | tr.Created = parsedTime(created) 255 | } 256 | if modified, ok := trMap[wot.KeyThingRegistrationModified].(string); ok { 257 | tr.Modified = parsedTime(modified) 258 | } 259 | if expires, ok := trMap[wot.KeyThingRegistrationExpires].(string); ok { 260 | tr.Expires = parsedTime(expires) 261 | } 262 | if ttl, ok := trMap[wot.KeyThingRegistrationTTL].(float64); ok { 263 | tr.TTL = &ttl 264 | } 265 | 266 | return &tr 267 | } 268 | } 269 | // not found 270 | return nil 271 | } 272 | 273 | func computeExpiry(tr *wot.ThingRegistration, now time.Time) *time.Time { 274 | 275 | if tr != nil { 276 | if tr.TTL != nil { 277 | // calculate expiry as now+ttl 278 | expires := now.Add(time.Duration(*tr.TTL * 1e9)) 279 | return &expires 280 | } else if tr.Expires != nil { 281 | return tr.Expires 282 | } 283 | } 284 | // no expiry 285 | return nil 286 | } 287 | 288 | func ThingExpires(tr *wot.ThingRegistration) *time.Time { 289 | if tr != nil { 290 | return tr.Expires 291 | } 292 | // no expiry 293 | return nil 294 | } 295 | 296 | func ThingTTL(tr *wot.ThingRegistration) *float64 { 297 | if tr != nil { 298 | return tr.TTL 299 | } 300 | // no TTL 301 | return nil 302 | } 303 | 304 | // basicTypeFromXPathStr is a hack to get the actual data type from xpath.TextNode 305 | // Note: This might cause unexpected behaviour e.g. if user explicitly set string value to "true" or "false" 306 | func basicTypeFromXPathStr(strVal string) interface{} { 307 | floatVal, err := strconv.ParseFloat(strVal, 64) 308 | if err == nil { 309 | return floatVal 310 | } 311 | // string value is set to "true" or "false" by the library for boolean values. 312 | boolVal, err := strconv.ParseBool(strVal) // bit value is set to true or false by the library. 313 | if err == nil { 314 | return boolVal 315 | } 316 | return strVal 317 | } 318 | 319 | // getObjectFromXPathNode gets the concrete object from node by parsing the node recursively. 320 | // Ideally this function needs to be part of the library itself 321 | func getObjectFromXPathNode(n *xpath.Node) interface{} { 322 | 323 | if n.Type == xpath.TextNode { // if top most element is of type textnode, then just return the value 324 | return basicTypeFromXPathStr(n.Data) 325 | } 326 | 327 | if n.FirstChild != nil && n.FirstChild.Data == "" { // in case of array, there will be no wot.Key 328 | retArray := make([]interface{}, 0) 329 | for child := n.FirstChild; child != nil; child = child.NextSibling { 330 | retArray = append(retArray, getObjectFromXPathNode(child)) 331 | } 332 | return retArray 333 | } else { // normal map 334 | retMap := make(map[string]interface{}) 335 | 336 | for child := n.FirstChild; child != nil; child = child.NextSibling { 337 | if child.Type != xpath.TextNode { 338 | retMap[child.Data] = getObjectFromXPathNode(child) 339 | } else { 340 | return basicTypeFromXPathStr(child.Data) 341 | } 342 | } 343 | return retMap 344 | } 345 | } 346 | 347 | func (c *Controller) cleanExpired() { 348 | defer func() { 349 | if r := recover(); r != nil { 350 | log.Printf("panic: %v\n%s\n", r, debug.Stack()) 351 | go c.cleanExpired() 352 | } 353 | }() 354 | 355 | for t := range time.Tick(controllerExpiryCleanupInterval) { 356 | var expiredServices []ThingDescription 357 | 358 | for td := range c.storage.iterate() { 359 | if expires := ThingExpires(ThingRegistration(td)); expires != nil { 360 | if t.After(*expires) { 361 | expiredServices = append(expiredServices, td) 362 | } 363 | } 364 | } 365 | 366 | for i := range expiredServices { 367 | id := expiredServices[i][wot.KeyThingID].(string) 368 | log.Printf("cleanExpired() Removing expired registration: %s", id) 369 | err := c.storage.delete(id) 370 | if err != nil { 371 | log.Printf("cleanExpired() Error removing expired registration: %s: %s", id, err) 372 | continue 373 | } 374 | } 375 | } 376 | } 377 | 378 | // Stop the controller 379 | func (c *Controller) Stop() { 380 | //log.Println("Stopped the controller.") 381 | } 382 | 383 | // Generate a unique URN 384 | func (c *Controller) newURN() string { 385 | return fmt.Sprintf("urn:uuid:%s", uuid.NewV4().String()) 386 | } 387 | -------------------------------------------------------------------------------- /catalog/controller_test.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "reflect" 8 | "strconv" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | uuid "github.com/satori/go.uuid" 14 | ) 15 | 16 | func setup(t *testing.T) CatalogController { 17 | var ( 18 | storage Storage 19 | tempDir = fmt.Sprintf("%s/thing-directory/test-%s-ldb", 20 | strings.Replace(os.TempDir(), "\\", "/", -1), uuid.NewV4()) 21 | ) 22 | 23 | err := loadSchema() 24 | if err != nil { 25 | t.Fatalf("error loading WoT Thing Description schema: %s", err) 26 | } 27 | 28 | switch TestStorageType { 29 | case BackendLevelDB: 30 | storage, err = NewLevelDBStorage(tempDir, nil) 31 | if err != nil { 32 | t.Fatalf("error creating leveldb storage: %s", err) 33 | } 34 | } 35 | 36 | controller, err := NewController(storage) 37 | if err != nil { 38 | storage.Close() 39 | t.Fatalf("error creating controller: %s", err) 40 | } 41 | 42 | t.Cleanup(func() { 43 | // t.Logf("Cleaning up...") 44 | controller.Stop() 45 | storage.Close() 46 | err = os.RemoveAll(tempDir) // Remove temp files 47 | if err != nil { 48 | t.Fatalf("error removing test files: %s", err) 49 | } 50 | }) 51 | 52 | return controller 53 | } 54 | 55 | func TestControllerAdd(t *testing.T) { 56 | controller := setup(t) 57 | 58 | t.Run("user-defined ID", func(t *testing.T) { 59 | 60 | var td = map[string]any{ 61 | "@context": "https://www.w3.org/2019/wot/td/v1", 62 | "id": "urn:example:test/thing1", 63 | "title": "example thing", 64 | "security": []string{"basic_sc"}, 65 | "securityDefinitions": map[string]any{ 66 | "basic_sc": map[string]string{ 67 | "in": "header", 68 | "scheme": "basic", 69 | }, 70 | }, 71 | } 72 | 73 | id, err := controller.add(td) 74 | if err != nil { 75 | t.Fatalf("Unexpected error on add: %s", err) 76 | } 77 | if id != td["id"] { 78 | t.Fatalf("User defined ID is not returned. Getting %s instead of %s\n", id, td["id"]) 79 | } 80 | 81 | // add it again 82 | _, err = controller.add(td) 83 | if err == nil { 84 | t.Error("Didn't get any error when adding a service with non-unique id.") 85 | } 86 | }) 87 | 88 | t.Run("system-generated ID", func(t *testing.T) { 89 | // System-generated id 90 | var td = map[string]any{ 91 | "@context": "https://www.w3.org/2019/wot/td/v1", 92 | "title": "example thing", 93 | "security": []string{"basic_sc"}, 94 | "securityDefinitions": map[string]any{ 95 | "basic_sc": map[string]string{ 96 | "in": "header", 97 | "scheme": "basic", 98 | }, 99 | }, 100 | } 101 | 102 | id, err := controller.add(td) 103 | if err != nil { 104 | t.Fatalf("Unexpected error on add: %s", err) 105 | } 106 | if !strings.HasPrefix(id, "urn:") { 107 | t.Fatalf("System-generated ID is not a URN. Got: %s\n", id) 108 | } 109 | _, err = uuid.FromString(strings.TrimPrefix(id, "urn:")) 110 | if err == nil { 111 | t.Fatalf("System-generated ID is not a uuid. Got: %s\n", id) 112 | } 113 | }) 114 | } 115 | 116 | func TestControllerGet(t *testing.T) { 117 | controller := setup(t) 118 | 119 | var td = map[string]any{ 120 | "@context": "https://www.w3.org/2019/wot/td/v1", 121 | "id": "urn:example:test/thing1", 122 | "title": "example thing", 123 | "security": []string{"basic_sc"}, 124 | "securityDefinitions": map[string]any{ 125 | "basic_sc": map[string]string{ 126 | "in": "header", 127 | "scheme": "basic", 128 | }, 129 | }, 130 | } 131 | 132 | id, err := controller.add(td) 133 | if err != nil { 134 | t.Fatalf("Unexpected error on add: %s", err) 135 | } 136 | 137 | t.Run("retrieve", func(t *testing.T) { 138 | storedTD, err := controller.get(id) 139 | if err != nil { 140 | t.Fatalf("Error retrieving: %s", err) 141 | } 142 | 143 | // set system-generated attributes 144 | storedTD["registration"] = td["registration"] 145 | 146 | if !serializedEqual(td, storedTD) { 147 | t.Fatalf("Added and retrieved TDs are not equal:\n Added:\n%v\n Retrieved:\n%v\n", td, storedTD) 148 | } 149 | }) 150 | 151 | t.Run("retrieve non-existed", func(t *testing.T) { 152 | _, err := controller.get("some_id") 153 | if err != nil { 154 | switch err.(type) { 155 | case *NotFoundError: 156 | // good 157 | default: 158 | t.Fatalf("TD doesn't exist. Expected NotFoundError but got %s", err) 159 | } 160 | } else { 161 | t.Fatal("No error when retrieving a non-existed TD.") 162 | } 163 | }) 164 | 165 | } 166 | 167 | func TestControllerUpdate(t *testing.T) { 168 | controller := setup(t) 169 | 170 | var td = map[string]any{ 171 | "@context": "https://www.w3.org/2019/wot/td/v1", 172 | "id": "urn:example:test/thing1", 173 | "title": "example thing", 174 | "security": []string{"basic_sc"}, 175 | "securityDefinitions": map[string]any{ 176 | "basic_sc": map[string]string{ 177 | "in": "header", 178 | "scheme": "basic", 179 | }, 180 | }, 181 | } 182 | 183 | id, err := controller.add(td) 184 | if err != nil { 185 | t.Fatalf("Unexpected error on add: %s", err) 186 | } 187 | 188 | t.Run("update attributes", func(t *testing.T) { 189 | // Change 190 | td["title"] = "new title" 191 | td["description"] = "description of the thing" 192 | 193 | err = controller.update(id, td) 194 | if err != nil { 195 | t.Fatal("Error updating TD:", err.Error()) 196 | } 197 | 198 | storedTD, err := controller.get(id) 199 | if err != nil { 200 | t.Fatal("Error retrieving TD:", err.Error()) 201 | } 202 | 203 | // set system-generated attributes 204 | storedTD["registration"] = td["registration"] 205 | 206 | if !serializedEqual(td, storedTD) { 207 | t.Fatalf("Updates were not applied or returned:\n Expected:\n%v\n Retrieved:\n%v\n", td, storedTD) 208 | } 209 | }) 210 | } 211 | 212 | func TestControllerDelete(t *testing.T) { 213 | controller := setup(t) 214 | 215 | var td = map[string]any{ 216 | "@context": "https://www.w3.org/2019/wot/td/v1", 217 | "id": "urn:example:test/thing1", 218 | "title": "example thing", 219 | "security": []string{"basic_sc"}, 220 | "securityDefinitions": map[string]any{ 221 | "basic_sc": map[string]string{ 222 | "in": "header", 223 | "scheme": "basic", 224 | }, 225 | }, 226 | } 227 | 228 | id, err := controller.add(td) 229 | if err != nil { 230 | t.Fatalf("Error adding a TD: %s", err) 231 | } 232 | 233 | t.Run("delete", func(t *testing.T) { 234 | err = controller.delete(id) 235 | if err != nil { 236 | t.Fatalf("Error deleting TD: %s", err) 237 | } 238 | }) 239 | 240 | t.Run("delete a deleted TD", func(t *testing.T) { 241 | err = controller.delete(id) 242 | if err != nil { 243 | switch err.(type) { 244 | case *NotFoundError: 245 | // good 246 | default: 247 | t.Fatalf("TD was deleted. Expected NotFoundError but got %s", err) 248 | } 249 | } else { 250 | t.Fatalf("No error when deleting a deleted TD: %s", err) 251 | } 252 | }) 253 | 254 | t.Run("retrieve a deleted TD", func(t *testing.T) { 255 | _, err = controller.get(id) 256 | if err != nil { 257 | switch err.(type) { 258 | case *NotFoundError: 259 | // good 260 | default: 261 | t.Fatalf("TD was deleted. Expected NotFoundError but got %s", err) 262 | } 263 | } else { 264 | t.Fatal("No error when retrieving a deleted TD") 265 | } 266 | }) 267 | } 268 | 269 | func TestControllerListPaginate(t *testing.T) { 270 | controller := setup(t) 271 | 272 | // add several entries 273 | var addedTDs []ThingDescription 274 | for i := 0; i < 5; i++ { 275 | var td = map[string]any{ 276 | "@context": "https://www.w3.org/2019/wot/td/v1", 277 | "id": "urn:example:test/thing_" + strconv.Itoa(i), 278 | "title": "example thing", 279 | "security": []string{"basic_sc"}, 280 | "securityDefinitions": map[string]any{ 281 | "basic_sc": map[string]string{ 282 | "in": "header", 283 | "scheme": "basic", 284 | }, 285 | }, 286 | } 287 | 288 | id, err := controller.add(td) 289 | if err != nil { 290 | t.Fatal("Error adding a TD:", err.Error()) 291 | } 292 | sd, err := controller.get(id) 293 | if err != nil { 294 | t.Fatal("Error retrieving TD:", err.Error()) 295 | } 296 | 297 | addedTDs = append(addedTDs, sd) 298 | } 299 | 300 | var list []ThingDescription 301 | 302 | // [0-3) 303 | TDs, err := controller.listPaginate(0, 3) 304 | if err != nil { 305 | t.Fatal("Error getting list of TDs:", err.Error()) 306 | } 307 | if len(TDs) != 3 { 308 | t.Fatalf("Page has %d entries instead of 3", len(TDs)) 309 | } 310 | list = append(list, TDs...) 311 | 312 | // [3-end) 313 | TDs, err = controller.listPaginate(3, 10) 314 | if err != nil { 315 | t.Fatal("Error getting list of TDs:", err.Error()) 316 | } 317 | if len(TDs) != 2 { 318 | t.Fatalf("Page has %d entries instead of 2", len(TDs)) 319 | } 320 | list = append(list, TDs...) 321 | 322 | if len(list) != 5 { 323 | t.Fatalf("Catalog contains %d entries instead of 5", len(list)) 324 | } 325 | 326 | // compare added and collection 327 | for i, sd := range list { 328 | if !reflect.DeepEqual(addedTDs[i], sd) { 329 | t.Fatalf("TDs listed in catalog is different with the one stored:\n Stored:\n%v\n Listed\n%v\n", 330 | addedTDs[i], sd) 331 | } 332 | } 333 | } 334 | 335 | func TestControllerFilter(t *testing.T) { 336 | controller := setup(t) 337 | 338 | for i := 0; i < 5; i++ { 339 | var td = map[string]any{ 340 | "@context": "https://www.w3.org/2019/wot/td/v1", 341 | "id": "urn:example:test/thing_" + strconv.Itoa(i), 342 | "title": "example thing", 343 | "security": []string{"basic_sc"}, 344 | "securityDefinitions": map[string]any{ 345 | "basic_sc": map[string]string{ 346 | "in": "header", 347 | "scheme": "basic", 348 | }, 349 | }, 350 | } 351 | 352 | _, err := controller.add(td) 353 | if err != nil { 354 | t.Fatal("Error adding a TD:", err.Error()) 355 | } 356 | } 357 | 358 | _, err := controller.add(map[string]any{ 359 | "@context": "https://www.w3.org/2019/wot/td/v1", 360 | "id": "urn:example:test/thing_x", 361 | "title": "interesting thing", 362 | "security": []string{"basic_sc"}, 363 | "securityDefinitions": map[string]any{ 364 | "basic_sc": map[string]string{ 365 | "in": "header", 366 | "scheme": "basic", 367 | }, 368 | }, 369 | }) 370 | if err != nil { 371 | t.Fatal("Error adding a TD:", err.Error()) 372 | } 373 | 374 | _, err = controller.add(map[string]any{ 375 | "@context": "https://www.w3.org/2019/wot/td/v1", 376 | "id": "urn:example:test/thing_y", 377 | "title": "interesting thing", 378 | "security": []string{"basic_sc"}, 379 | "securityDefinitions": map[string]any{ 380 | "basic_sc": map[string]string{ 381 | "in": "header", 382 | "scheme": "basic", 383 | }, 384 | }, 385 | }) 386 | if err != nil { 387 | t.Fatal("Error adding a TD:", err.Error()) 388 | } 389 | 390 | t.Run("JSONPath filter", func(t *testing.T) { 391 | b, err := controller.filterJSONPathBytes("$[?(@.title=='interesting thing')]") 392 | if err != nil { 393 | t.Fatal("Error filtering:", err.Error()) 394 | } 395 | var TDs []ThingDescription 396 | err = json.Unmarshal(b, &TDs) 397 | if err != nil { 398 | t.Fatal("Error unmarshalling output:", err.Error()) 399 | } 400 | if len(TDs) != 2 { 401 | t.Fatalf("Returned %d instead of 2 TDs when filtering based on title: \n%v", len(TDs), TDs) 402 | } 403 | for _, td := range TDs { 404 | if td["title"].(string) != "interesting thing" { 405 | t.Fatal("Wrong results when filtering based on title:\n", td) 406 | } 407 | } 408 | }) 409 | 410 | } 411 | 412 | func TestControllerCleanExpired(t *testing.T) { 413 | 414 | // shorten controller's cleanup interval to test quickly 415 | controllerExpiryCleanupInterval = 1 * time.Second 416 | const wait = 3 * time.Second 417 | 418 | controller := setup(t) 419 | 420 | var td = ThingDescription{ 421 | "@context": "https://www.w3.org/2019/wot/td/v1", 422 | "id": "urn:example:test/thing1", 423 | "title": "example thing", 424 | "security": []string{"basic_sc"}, 425 | "securityDefinitions": map[string]any{ 426 | "basic_sc": map[string]string{ 427 | "in": "header", 428 | "scheme": "basic", 429 | }, 430 | }, 431 | "registration": map[string]any{ 432 | "ttl": 0.1, 433 | }, 434 | } 435 | 436 | id, err := controller.add(td) 437 | if err != nil { 438 | t.Fatal("Error adding a TD:", err.Error()) 439 | } 440 | 441 | time.Sleep(wait) 442 | 443 | _, err = controller.get(id) 444 | if err != nil { 445 | switch err.(type) { 446 | case *NotFoundError: 447 | // good 448 | default: 449 | t.Fatalf("Got an error other than NotFoundError when getting an expired TD: %s\n", err) 450 | } 451 | } else { 452 | t.Fatalf("Expired TD was not removed") 453 | } 454 | } 455 | -------------------------------------------------------------------------------- /catalog/errors.go: -------------------------------------------------------------------------------- 1 | 2 | package catalog 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | 10 | "github.com/tinyiot/thing-directory/wot" 11 | uuid "github.com/satori/go.uuid" 12 | ) 13 | 14 | // Not Found 15 | type NotFoundError struct{ S string } 16 | 17 | func (e *NotFoundError) Error() string { return e.S } 18 | 19 | // Conflict (non-unique id, assignment to read-only data) 20 | type ConflictError struct{ S string } 21 | 22 | func (e *ConflictError) Error() string { return e.S } 23 | 24 | // Bad Request 25 | type BadRequestError struct{ S string } 26 | 27 | func (e *BadRequestError) Error() string { return e.S } 28 | 29 | // Validation error (HTTP Bad Request) 30 | type ValidationError struct { 31 | ValidationErrors []wot.ValidationError 32 | } 33 | 34 | func (e *ValidationError) Error() string { return "validation errors" } 35 | 36 | // ErrorResponse writes error to HTTP ResponseWriter 37 | func ErrorResponse(w http.ResponseWriter, code int, msg ...interface{}) { 38 | ProblemDetailsResponse(w, wot.ProblemDetails{ 39 | Status: code, 40 | Detail: fmt.Sprint(msg...), 41 | }) 42 | } 43 | 44 | func ValidationErrorResponse(w http.ResponseWriter, validationIssues []wot.ValidationError) { 45 | ProblemDetailsResponse(w, wot.ProblemDetails{ 46 | Status: http.StatusBadRequest, 47 | Detail: "The input did not pass the JSON Schema validation", 48 | ValidationErrors: validationIssues, 49 | }) 50 | } 51 | 52 | // ErrorResponse writes error to HTTP ResponseWriter 53 | func ProblemDetailsResponse(w http.ResponseWriter, pd wot.ProblemDetails) { 54 | if pd.Title == "" { 55 | pd.Title = http.StatusText(pd.Status) 56 | if pd.Title == "" { 57 | panic(fmt.Sprint("Invalid HTTP status code: ", pd.Status)) 58 | } 59 | } 60 | pd.Instance = "/errors/" + uuid.NewV4().String() 61 | log.Println("Problem Details instance:", pd.Instance) 62 | if pd.Status >= 500 { 63 | log.Println("ERROR:", pd.Detail) 64 | } 65 | b, err := json.Marshal(pd) 66 | if err != nil { 67 | log.Printf("ERROR serializing error object: %s", err) 68 | } 69 | w.Header().Set("Content-Type", "application/problem+json") 70 | w.WriteHeader(pd.Status) 71 | _, err = w.Write(b) 72 | if err != nil { 73 | log.Printf("ERROR writing HTTP response: %s", err) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /catalog/events.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | // EventListener interface that listens to TDD events. 4 | type EventListener interface { 5 | CreateHandler(new ThingDescription) error 6 | UpdateHandler(old ThingDescription, new ThingDescription) error 7 | DeleteHandler(old ThingDescription) error 8 | } 9 | 10 | // eventHandler implements sequential fav-out/fan-in of events from registry 11 | type eventHandler []EventListener 12 | 13 | func (h eventHandler) created(new ThingDescription) error { 14 | for i := range h { 15 | err := h[i].CreateHandler(new) 16 | if err != nil { 17 | return err 18 | } 19 | } 20 | return nil 21 | } 22 | 23 | func (h eventHandler) updated(old ThingDescription, new ThingDescription) error { 24 | for i := range h { 25 | err := h[i].UpdateHandler(old, new) 26 | if err != nil { 27 | return err 28 | } 29 | } 30 | return nil 31 | } 32 | 33 | func (h eventHandler) deleted(old ThingDescription) error { 34 | for i := range h { 35 | err := h[i].DeleteHandler(old) 36 | if err != nil { 37 | return err 38 | } 39 | } 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /catalog/http.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "strconv" 10 | 11 | "github.com/gorilla/mux" 12 | "github.com/tinyiot/thing-directory/wot" 13 | ) 14 | 15 | const ( 16 | // query parameters 17 | QueryParamOffset = "offset" 18 | QueryParamLimit = "limit" 19 | QueryParamJSONPath = "jsonpath" 20 | QueryParamSearchQuery = "query" 21 | ) 22 | 23 | type ValidationResult struct { 24 | Valid bool `json:"valid"` 25 | Errors []string `json:"errors"` 26 | } 27 | 28 | type HTTPAPI struct { 29 | controller CatalogController 30 | } 31 | 32 | func NewHTTPAPI(controller CatalogController, version string) *HTTPAPI { 33 | return &HTTPAPI{ 34 | controller: controller, 35 | } 36 | } 37 | 38 | // Post handler creates one item 39 | func (a *HTTPAPI) Post(w http.ResponseWriter, req *http.Request) { 40 | body, err := ioutil.ReadAll(req.Body) 41 | req.Body.Close() 42 | if err != nil { 43 | ErrorResponse(w, http.StatusBadRequest, err.Error()) 44 | return 45 | } 46 | 47 | var td ThingDescription 48 | if err := json.Unmarshal(body, &td); err != nil { 49 | ErrorResponse(w, http.StatusBadRequest, "Error processing the request:", err.Error()) 50 | return 51 | } 52 | 53 | if td[wot.KeyThingID] != nil { 54 | id, ok := td[wot.KeyThingID].(string) 55 | if !ok || id != "" { 56 | ErrorResponse(w, http.StatusBadRequest, "Registering with user-defined id is not possible using a POST request.") 57 | return 58 | } 59 | } 60 | 61 | id, err := a.controller.add(td) 62 | if err != nil { 63 | switch err.(type) { 64 | case *ConflictError: 65 | ErrorResponse(w, http.StatusConflict, "Error creating the resource:", err.Error()) 66 | return 67 | case *BadRequestError: 68 | ErrorResponse(w, http.StatusBadRequest, "Invalid registration:", err.Error()) 69 | return 70 | case *ValidationError: 71 | ValidationErrorResponse(w, err.(*ValidationError).ValidationErrors) 72 | return 73 | default: 74 | ErrorResponse(w, http.StatusInternalServerError, "Error creating the registration:", err.Error()) 75 | return 76 | } 77 | } 78 | 79 | w.Header().Set("Location", id) 80 | w.WriteHeader(http.StatusCreated) 81 | } 82 | 83 | // Put handler updates an existing item (Response: StatusOK) 84 | // If the item does not exist, a new one will be created with the given id (Response: StatusCreated) 85 | func (a *HTTPAPI) Put(w http.ResponseWriter, req *http.Request) { 86 | params := mux.Vars(req) 87 | 88 | body, err := ioutil.ReadAll(req.Body) 89 | req.Body.Close() 90 | if err != nil { 91 | ErrorResponse(w, http.StatusBadRequest, err.Error()) 92 | return 93 | } 94 | 95 | var td ThingDescription 96 | if err := json.Unmarshal(body, &td); err != nil { 97 | ErrorResponse(w, http.StatusBadRequest, "Error processing the request:", err.Error()) 98 | return 99 | } 100 | 101 | if id, ok := td[wot.KeyThingID].(string); !ok || id == "" { 102 | ErrorResponse(w, http.StatusBadRequest, "Registration without id is not possible using a PUT request.") 103 | return 104 | } 105 | if params["id"] != td[wot.KeyThingID] { 106 | ErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("Resource id in path (%s) does not match the id in body (%s)", params["id"], td[wot.KeyThingID])) 107 | return 108 | } 109 | 110 | err = a.controller.update(params["id"], td) 111 | if err != nil { 112 | switch err.(type) { 113 | case *NotFoundError: 114 | // Create a new device with the given id 115 | id, err := a.controller.add(td) 116 | if err != nil { 117 | switch err.(type) { 118 | case *ConflictError: 119 | ErrorResponse(w, http.StatusConflict, "Error creating the registration:", err.Error()) 120 | return 121 | case *BadRequestError: 122 | ErrorResponse(w, http.StatusBadRequest, "Invalid registration:", err.Error()) 123 | return 124 | case *ValidationError: 125 | ValidationErrorResponse(w, err.(*ValidationError).ValidationErrors) 126 | return 127 | default: 128 | ErrorResponse(w, http.StatusInternalServerError, "Error creating the registration:", err.Error()) 129 | return 130 | } 131 | } 132 | w.Header().Set("Location", id) 133 | w.WriteHeader(http.StatusCreated) 134 | return 135 | case *BadRequestError: 136 | ErrorResponse(w, http.StatusBadRequest, "Invalid registration:", err.Error()) 137 | return 138 | case *ValidationError: 139 | ValidationErrorResponse(w, err.(*ValidationError).ValidationErrors) 140 | return 141 | default: 142 | ErrorResponse(w, http.StatusInternalServerError, "Error updating the registration:", err.Error()) 143 | return 144 | } 145 | } 146 | 147 | w.WriteHeader(http.StatusNoContent) 148 | } 149 | 150 | // Patch updates parts or all of an existing item (Response: StatusOK) 151 | func (a *HTTPAPI) Patch(w http.ResponseWriter, req *http.Request) { 152 | params := mux.Vars(req) 153 | 154 | body, err := ioutil.ReadAll(req.Body) 155 | req.Body.Close() 156 | if err != nil { 157 | ErrorResponse(w, http.StatusBadRequest, err.Error()) 158 | return 159 | } 160 | 161 | var td ThingDescription 162 | if err := json.Unmarshal(body, &td); err != nil { 163 | ErrorResponse(w, http.StatusBadRequest, "Error processing the request:", err.Error()) 164 | return 165 | } 166 | 167 | if id, ok := td[wot.KeyThingID].(string); ok && id == "" { 168 | if params["id"] != td[wot.KeyThingID] { 169 | ErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("Resource id in path (%s) does not match the id in body (%s)", params["id"], td[wot.KeyThingID])) 170 | return 171 | } 172 | } 173 | 174 | err = a.controller.patch(params["id"], td) 175 | if err != nil { 176 | switch err.(type) { 177 | case *NotFoundError: 178 | ErrorResponse(w, http.StatusNotFound, "Invalid registration:", err.Error()) 179 | return 180 | case *BadRequestError: 181 | ErrorResponse(w, http.StatusBadRequest, "Invalid registration:", err.Error()) 182 | return 183 | case *ValidationError: 184 | ValidationErrorResponse(w, err.(*ValidationError).ValidationErrors) 185 | return 186 | default: 187 | ErrorResponse(w, http.StatusInternalServerError, "Error updating the registration:", err.Error()) 188 | return 189 | } 190 | } 191 | 192 | w.WriteHeader(http.StatusNoContent) 193 | } 194 | 195 | // Get handler get one item 196 | func (a *HTTPAPI) Get(w http.ResponseWriter, req *http.Request) { 197 | params := mux.Vars(req) 198 | 199 | td, err := a.controller.get(params["id"]) 200 | if err != nil { 201 | switch err.(type) { 202 | case *NotFoundError: 203 | ErrorResponse(w, http.StatusNotFound, err.Error()) 204 | return 205 | default: 206 | ErrorResponse(w, http.StatusInternalServerError, "Error retrieving the registration: ", err.Error()) 207 | return 208 | } 209 | } 210 | 211 | b, err := json.Marshal(td) 212 | if err != nil { 213 | ErrorResponse(w, http.StatusInternalServerError, err.Error()) 214 | return 215 | } 216 | 217 | w.Header().Set("Content-Type", wot.MediaTypeThingDescription) 218 | _, err = w.Write(b) 219 | if err != nil { 220 | log.Printf("ERROR writing HTTP response: %s", err) 221 | } 222 | } 223 | 224 | // Delete removes one item 225 | func (a *HTTPAPI) Delete(w http.ResponseWriter, req *http.Request) { 226 | params := mux.Vars(req) 227 | 228 | err := a.controller.delete(params["id"]) 229 | if err != nil { 230 | switch err.(type) { 231 | case *NotFoundError: 232 | ErrorResponse(w, http.StatusNotFound, err.Error()) 233 | return 234 | default: 235 | ErrorResponse(w, http.StatusInternalServerError, "Error deleting the registration:", err.Error()) 236 | return 237 | } 238 | } 239 | 240 | w.WriteHeader(http.StatusNoContent) 241 | } 242 | 243 | // List lists entries in paginated format or as a stream 244 | func (a *HTTPAPI) List(w http.ResponseWriter, req *http.Request) { 245 | err := req.ParseForm() 246 | if err != nil { 247 | ErrorResponse(w, http.StatusBadRequest, "Error parsing the query:", err.Error()) 248 | return 249 | } 250 | 251 | // pagination is done only when limit is set 252 | if req.Form.Get(QueryParamLimit) != "" { 253 | a.listPaginated(w, req) 254 | return 255 | } else { 256 | a.listStream(w, req) 257 | return 258 | } 259 | } 260 | 261 | func (a *HTTPAPI) listPaginated(w http.ResponseWriter, req *http.Request) { 262 | var err error 263 | var limit, offset int 264 | 265 | limitStr := req.Form.Get(QueryParamLimit) 266 | if limitStr != "" { 267 | limit, err = strconv.Atoi(limitStr) 268 | if err != nil { 269 | ErrorResponse(w, http.StatusBadRequest, err.Error()) 270 | return 271 | } 272 | } 273 | 274 | offsetStr := req.Form.Get(QueryParamOffset) 275 | if offsetStr != "" { 276 | offset, err = strconv.Atoi(offsetStr) 277 | if err != nil { 278 | ErrorResponse(w, http.StatusBadRequest, err.Error()) 279 | return 280 | } 281 | } 282 | 283 | items, err := a.controller.listPaginate(offset, limit) 284 | if err != nil { 285 | switch err.(type) { 286 | case *BadRequestError: 287 | ErrorResponse(w, http.StatusBadRequest, err.Error()) 288 | return 289 | default: 290 | ErrorResponse(w, http.StatusInternalServerError, err.Error()) 291 | return 292 | } 293 | } 294 | 295 | b, err := json.Marshal(items) 296 | if err != nil { 297 | ErrorResponse(w, http.StatusInternalServerError, err.Error()) 298 | return 299 | } 300 | 301 | w.Header().Set("Content-Type", wot.MediaTypeJSONLD) 302 | _, err = w.Write(b) 303 | if err != nil { 304 | log.Printf("ERROR writing HTTP response: %s", err) 305 | } 306 | } 307 | 308 | func (a *HTTPAPI) listStream(w http.ResponseWriter, req *http.Request) { 309 | //flusher, ok := w.(http.Flusher) 310 | //if !ok { 311 | // panic("expected http.ResponseWriter to be an http.Flusher") 312 | //} 313 | 314 | w.Header().Set("Content-Type", wot.MediaTypeJSONLD) 315 | w.Header().Set("X-Content-Type-Options", "nosniff") // tell clients not to infer content type from partial body 316 | 317 | _, err := fmt.Fprintf(w, "[") 318 | if err != nil { 319 | log.Printf("ERROR writing HTTP response: %s", err) 320 | } 321 | 322 | first := true 323 | for item := range a.controller.iterateBytes(req.Context()) { 324 | select { 325 | case <-req.Context().Done(): 326 | log.Println("Cancelled by client.") 327 | if err := req.Context().Err(); err != nil { 328 | log.Printf("Client err: %s", err) 329 | return 330 | } 331 | 332 | default: 333 | if first { 334 | first = false 335 | } else { 336 | _, err := fmt.Fprint(w, ",") 337 | if err != nil { 338 | log.Printf("ERROR writing HTTP response: %s", err) 339 | } 340 | } 341 | 342 | _, err := w.Write(item) 343 | if err != nil { 344 | log.Printf("ERROR writing HTTP response: %s", err) 345 | } 346 | //time.Sleep(500 * time.Millisecond) 347 | //flusher.Flush() 348 | } 349 | 350 | } 351 | _, err = fmt.Fprintf(w, "]") 352 | if err != nil { 353 | log.Printf("ERROR writing HTTP response: %s", err) 354 | } 355 | } 356 | 357 | // SearchJSONPath returns the JSONPath query result 358 | func (a *HTTPAPI) SearchJSONPath(w http.ResponseWriter, req *http.Request) { 359 | err := req.ParseForm() 360 | if err != nil { 361 | ErrorResponse(w, http.StatusBadRequest, "Error parsing the query: ", err.Error()) 362 | return 363 | } 364 | 365 | query := req.Form.Get(QueryParamSearchQuery) 366 | if query == "" { 367 | ErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("No value for %s argument", QueryParamSearchQuery)) 368 | return 369 | } 370 | w.Header().Add("X-Request-Query", query) 371 | 372 | b, err := a.controller.filterJSONPathBytes(query) 373 | if err != nil { 374 | switch err.(type) { 375 | case *BadRequestError: 376 | ErrorResponse(w, http.StatusBadRequest, err.Error()) 377 | return 378 | default: 379 | ErrorResponse(w, http.StatusInternalServerError, err.Error()) 380 | return 381 | } 382 | } 383 | 384 | w.Header().Set("Content-Type", wot.MediaTypeJSON) 385 | w.Header().Set("X-Request-URL", req.RequestURI) 386 | _, err = w.Write(b) 387 | if err != nil { 388 | log.Printf("ERROR writing HTTP response: %s", err) 389 | return 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /catalog/ldbstorage.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "flag" 8 | "fmt" 9 | "log" 10 | "net/url" 11 | "sync" 12 | 13 | "github.com/syndtr/goleveldb/leveldb" 14 | "github.com/syndtr/goleveldb/leveldb/opt" 15 | ) 16 | 17 | // LevelDB storage 18 | type LevelDBStorage struct { 19 | db *leveldb.DB 20 | wg sync.WaitGroup 21 | } 22 | 23 | func NewLevelDBStorage(dsn string, opts *opt.Options) (Storage, error) { 24 | url, err := url.Parse(dsn) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | // Open the database file 30 | db, err := leveldb.OpenFile(url.Path, opts) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return &LevelDBStorage{db: db}, nil 36 | } 37 | 38 | // CRUD 39 | func (s *LevelDBStorage) add(id string, td ThingDescription) error { 40 | if id == "" { 41 | return fmt.Errorf("ID is not set") 42 | } 43 | 44 | bytes, err := json.Marshal(td) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | found, err := s.db.Has([]byte(id), nil) 50 | if err != nil { 51 | return err 52 | } 53 | if found { 54 | return &ConflictError{id + " is not unique"} 55 | } 56 | 57 | err = s.db.Put([]byte(id), bytes, nil) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func (s *LevelDBStorage) get(id string) (ThingDescription, error) { 66 | 67 | bytes, err := s.db.Get([]byte(id), nil) 68 | if err == leveldb.ErrNotFound { 69 | return nil, &NotFoundError{id + " is not found"} 70 | } else if err != nil { 71 | return nil, err 72 | } 73 | 74 | var td ThingDescription 75 | err = json.Unmarshal(bytes, &td) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | return td, nil 81 | } 82 | 83 | func (s *LevelDBStorage) update(id string, td ThingDescription) error { 84 | 85 | bytes, err := json.Marshal(td) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | found, err := s.db.Has([]byte(id), nil) 91 | if err != nil { 92 | return err 93 | } 94 | if !found { 95 | return &NotFoundError{id + " is not found"} 96 | } 97 | 98 | err = s.db.Put([]byte(id), bytes, nil) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | return nil 104 | } 105 | 106 | func (s *LevelDBStorage) delete(id string) error { 107 | found, err := s.db.Has([]byte(id), nil) 108 | if err != nil { 109 | return err 110 | } 111 | if !found { 112 | return &NotFoundError{id + " is not found"} 113 | } 114 | 115 | err = s.db.Delete([]byte(id), nil) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func (s *LevelDBStorage) listPaginate(offset, limit int) ([]ThingDescription, error) { 124 | 125 | // TODO: is there a better way to do this? 126 | TDs := make([]ThingDescription, 0, limit) 127 | s.wg.Add(1) 128 | iter := s.db.NewIterator(nil, nil) 129 | 130 | for i := 0; i < offset+limit && iter.Next(); i++ { 131 | if i >= offset && i < offset+limit { 132 | var td ThingDescription 133 | err := json.Unmarshal(iter.Value(), &td) 134 | if err != nil { 135 | return nil, err 136 | } 137 | TDs = append(TDs, td) 138 | } 139 | } 140 | iter.Release() 141 | s.wg.Done() 142 | err := iter.Error() 143 | if err != nil { 144 | return nil, err 145 | } 146 | 147 | return TDs, nil 148 | } 149 | 150 | func (s *LevelDBStorage) listAllBytes() ([]byte, error) { 151 | 152 | s.wg.Add(1) 153 | iter := s.db.NewIterator(nil, nil) 154 | 155 | var buffer bytes.Buffer 156 | buffer.WriteString("[") 157 | separator := byte(',') 158 | first := true 159 | for iter.Next() { 160 | if first { 161 | first = false 162 | } else { 163 | buffer.WriteByte(separator) 164 | } 165 | buffer.Write(iter.Value()) 166 | } 167 | buffer.WriteString("]") 168 | 169 | iter.Release() 170 | s.wg.Done() 171 | err := iter.Error() 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | return buffer.Bytes(), nil 177 | } 178 | 179 | func (s *LevelDBStorage) iterate() <-chan ThingDescription { 180 | serviceIter := make(chan ThingDescription) 181 | 182 | go func() { 183 | defer close(serviceIter) 184 | 185 | s.wg.Add(1) 186 | defer s.wg.Done() 187 | iter := s.db.NewIterator(nil, nil) 188 | defer iter.Release() 189 | 190 | for iter.Next() { 191 | var td ThingDescription 192 | err := json.Unmarshal(iter.Value(), &td) 193 | if err != nil { 194 | log.Printf("LevelDB Error: %s", err) 195 | return 196 | } 197 | serviceIter <- td 198 | } 199 | 200 | err := iter.Error() 201 | if err != nil { 202 | log.Printf("LevelDB Error: %s", err) 203 | } 204 | }() 205 | 206 | return serviceIter 207 | } 208 | 209 | func (s *LevelDBStorage) iterateBytes(ctx context.Context) <-chan []byte { 210 | bytesCh := make(chan []byte, 0) // must be zero 211 | 212 | go func() { 213 | defer close(bytesCh) 214 | 215 | s.wg.Add(1) 216 | defer s.wg.Done() 217 | iter := s.db.NewIterator(nil, nil) 218 | defer iter.Release() 219 | 220 | Loop: 221 | for iter.Next() { 222 | select { 223 | case <-ctx.Done(): 224 | //log.Println("LevelDB: canceled") 225 | break Loop 226 | default: 227 | b := make([]byte, len(iter.Value())) 228 | copy(b, iter.Value()) 229 | bytesCh <- b 230 | } 231 | } 232 | 233 | err := iter.Error() 234 | if err != nil { 235 | log.Printf("LevelDB Error: %s", err) 236 | } 237 | }() 238 | 239 | return bytesCh 240 | } 241 | 242 | func (s *LevelDBStorage) Close() { 243 | s.wg.Wait() 244 | err := s.db.Close() 245 | if err != nil { 246 | log.Printf("Error closing storage: %s", err) 247 | } 248 | if flag.Lookup("test.v") == nil { 249 | log.Println("Closed leveldb.") 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /catalog/main_test.go: -------------------------------------------------------------------------------- 1 | 2 | package catalog 3 | 4 | import ( 5 | "encoding/json" 6 | "os" 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/tinyiot/thing-directory/wot" 11 | ) 12 | 13 | const ( 14 | envTestSchemaPath = "TEST_SCHEMA_PATH" 15 | defaultSchemaPath = "../wot/wot_td_schema.json" 16 | ) 17 | 18 | type any = interface{} 19 | 20 | var ( 21 | TestSupportedBackends = map[string]bool{ 22 | BackendMemory: false, 23 | BackendLevelDB: true, 24 | } 25 | TestStorageType string 26 | ) 27 | 28 | func loadSchema() error { 29 | if wot.LoadedJSONSchemas() { 30 | return nil 31 | } 32 | path := os.Getenv(envTestSchemaPath) 33 | if path == "" { 34 | path = defaultSchemaPath 35 | } 36 | return wot.LoadJSONSchemas([]string{path}) 37 | } 38 | 39 | func serializedEqual(td1 ThingDescription, td2 ThingDescription) bool { 40 | // serialize to ease comparison of interfaces and concrete types 41 | tdBytes, _ := json.Marshal(td1) 42 | storedTDBytes, _ := json.Marshal(td2) 43 | 44 | return reflect.DeepEqual(tdBytes, storedTDBytes) 45 | } 46 | 47 | func TestMain(m *testing.M) { 48 | // run tests for each storage backend 49 | for b, supported := range TestSupportedBackends { 50 | if supported { 51 | TestStorageType = b 52 | if m.Run() == 1 { 53 | os.Exit(1) 54 | } 55 | } 56 | } 57 | os.Exit(0) 58 | } 59 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/url" 8 | 9 | "github.com/kelseyhightower/envconfig" 10 | "github.com/linksmart/go-sec/auth/obtainer" 11 | "github.com/linksmart/go-sec/auth/validator" 12 | "github.com/tinyiot/thing-directory/catalog" 13 | ) 14 | 15 | type Config struct { 16 | ServiceID string `json:"serviceID"` 17 | Description string `json:"description"` 18 | Validation Validation `json:"validation"` 19 | HTTP HTTPConfig `json:"http"` 20 | DNSSD DNSSDConfig `json:"dnssd"` 21 | Storage StorageConfig `json:"storage"` 22 | } 23 | 24 | type Validation struct { 25 | JSONSchemas []string `json:"jsonSchemas"` 26 | } 27 | 28 | type HTTPConfig struct { 29 | PublicEndpoint string `json:"publicEndpoint"` 30 | BindAddr string `json:"bindAddr"` 31 | BindPort int `json:"bindPort"` 32 | TLSConfig *TLSConfig `json:"tls"` 33 | Auth validator.Conf `json:"auth"` 34 | } 35 | 36 | type TLSConfig struct { 37 | Enabled bool `json:"enabled"` 38 | KeyFile string `json:"keyFile"` 39 | CertFile string `json:"certFile"` 40 | } 41 | 42 | type ServiceCatalog struct { 43 | Enabled bool `json:"enabled"` 44 | Discover bool `json:"discover"` 45 | Endpoint string `json:"endpoint"` 46 | TTL int `json:"ttl"` 47 | Auth obtainer.Conf `json:"auth"` 48 | } 49 | 50 | type DNSSDConfig struct { 51 | Publish struct { 52 | Enabled bool `json:"enabled"` 53 | Instance string `json:"instance"` 54 | Domain string `json:"domain"` 55 | Interfaces []string `json:"interfaces"` 56 | } 57 | } 58 | 59 | type StorageConfig struct { 60 | Type string `json:"type"` 61 | DSN string `json:"dsn"` 62 | } 63 | 64 | var supportedBackends = map[string]bool{ 65 | catalog.BackendMemory: false, 66 | catalog.BackendLevelDB: true, 67 | } 68 | 69 | func (c *Config) Validate() error { 70 | if c.HTTP.BindAddr == "" || c.HTTP.BindPort == 0 || c.HTTP.PublicEndpoint == "" { 71 | return fmt.Errorf("BindAddr, BindPort, and PublicEndpoint have to be defined") 72 | } 73 | _, err := url.Parse(c.HTTP.PublicEndpoint) 74 | if err != nil { 75 | return fmt.Errorf("PublicEndpoint should be a valid URL") 76 | } 77 | if c.HTTP.Auth.Enabled { 78 | // Validate ticket validator config 79 | err = c.HTTP.Auth.Validate() 80 | if err != nil { 81 | return fmt.Errorf("invalid auth: %s", err) 82 | } 83 | } 84 | 85 | _, err = url.Parse(c.Storage.DSN) 86 | if err != nil { 87 | return fmt.Errorf("storage DSN should be a valid URL") 88 | } 89 | if !supportedBackends[c.Storage.Type] { 90 | return fmt.Errorf("unsupported storage backend") 91 | } 92 | 93 | return err 94 | } 95 | 96 | func loadConfig(path string) (*Config, error) { 97 | file, err := ioutil.ReadFile(path) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | var config Config 103 | err = json.Unmarshal(file, &config) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | // Override loaded values with environment variables 109 | err = envconfig.Process("td", &config) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | if err = config.Validate(); err != nil { 115 | return nil, fmt.Errorf("invalid configuration: %s", err) 116 | } 117 | return &config, nil 118 | } 119 | -------------------------------------------------------------------------------- /discovery.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "strings" 8 | 9 | "github.com/grandcat/zeroconf" 10 | "github.com/tinyiot/thing-directory/wot" 11 | ) 12 | 13 | // escape special characters as recommended by https://tools.ietf.org/html/rfc6763#section-4.3 14 | func escapeDNSSDServiceInstance(instance string) (escaped string) { 15 | // replace \ by \\ 16 | escaped = strings.ReplaceAll(instance, "\\", "\\\\") 17 | // replace . by \. 18 | escaped = strings.ReplaceAll(escaped, ".", "\\.") 19 | return escaped 20 | } 21 | 22 | // register as a DNS-SD Service 23 | func registerDNSSDService(conf *Config) (func(), error) { 24 | instance := escapeDNSSDServiceInstance(conf.DNSSD.Publish.Instance) 25 | 26 | log.Printf("DNS-SD: registering as \"%s.%s.%s\", subtype: %s", 27 | instance, wot.DNSSDServiceType, conf.DNSSD.Publish.Domain, wot.DNSSDServiceSubtypeDirectory) 28 | 29 | var ifs []net.Interface 30 | 31 | for _, name := range conf.DNSSD.Publish.Interfaces { 32 | iface, err := net.InterfaceByName(name) 33 | if err != nil { 34 | return nil, fmt.Errorf("error finding interface %s: %s", name, err) 35 | } 36 | if (iface.Flags & net.FlagMulticast) > 0 { 37 | ifs = append(ifs, *iface) 38 | } else { 39 | return nil, fmt.Errorf("interface %s does not support multicast", name) 40 | } 41 | log.Printf("DNS-SD: will register to interface: %s", name) 42 | } 43 | 44 | if len(ifs) == 0 { 45 | log.Println("DNS-SD: publish interfaces not set. Will register to all interfaces with multicast support.") 46 | } 47 | 48 | sd, err := zeroconf.Register( 49 | instance, 50 | wot.DNSSDServiceType+","+wot.DNSSDServiceSubtypeDirectory, 51 | conf.DNSSD.Publish.Domain, 52 | conf.HTTP.BindPort, 53 | []string{"td=/td", "version=" + Version}, 54 | ifs, 55 | ) 56 | if err != nil { 57 | return sd.Shutdown, err 58 | } 59 | 60 | return sd.Shutdown, nil 61 | } 62 | -------------------------------------------------------------------------------- /discovery_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestEscapeDNSSDServiceInstance(t *testing.T) { 6 | t.Run("no escaping", func(t *testing.T) { 7 | instance := "thing-directory" 8 | escaped := escapeDNSSDServiceInstance(instance) 9 | if escaped != instance { 10 | t.Fatalf("Unexpected escaping of %s to %s", instance, escaped) 11 | } 12 | }) 13 | 14 | t.Run("escape dot", func(t *testing.T) { 15 | instance := "thing.directory" // from thing.directory 16 | expected := "thing\\.directory" // to thing\.directory 17 | escaped := escapeDNSSDServiceInstance(instance) 18 | if escaped != expected { 19 | t.Fatalf("Escaped value for %s is %s. Expected %s", instance, escaped, expected) 20 | } 21 | }) 22 | 23 | t.Run("escape backslash", func(t *testing.T) { 24 | instance := "thing\\directory" // from thing\directory 25 | expected := "thing\\\\directory" // to thing\\directory 26 | escaped := escapeDNSSDServiceInstance(instance) 27 | if escaped != expected { 28 | t.Fatalf("Escaped value for %s is %s. Expected %s", instance, escaped, expected) 29 | } 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tinyiot/thing-directory 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/antchfx/jsonquery v1.1.4 7 | github.com/bhmj/jsonslice v0.0.0-20200507101114-bc37219df21b 8 | github.com/codegangsta/negroni v1.0.0 9 | github.com/davecgh/go-spew v1.1.1 // indirect 10 | github.com/evanphx/json-patch/v5 v5.1.0 11 | github.com/gorilla/context v1.1.1 12 | github.com/gorilla/mux v1.7.3 13 | github.com/grandcat/zeroconf v1.0.1-0.20200528163356-cfc8183341d9 14 | github.com/justinas/alice v0.0.0-20160512134231-052b8b6c18ed 15 | github.com/kelseyhightower/envconfig v1.4.0 16 | github.com/linksmart/go-sec v1.4.2 17 | github.com/linksmart/service-catalog/v3 v3.0.0-beta.1.0.20200302143206-92739dd2a511 18 | github.com/miekg/dns v1.1.29 // indirect 19 | github.com/onsi/ginkgo v1.12.0 // indirect 20 | github.com/onsi/gomega v1.9.0 // indirect 21 | github.com/rs/cors v1.7.0 22 | github.com/satori/go.uuid v1.2.0 23 | github.com/syndtr/goleveldb v1.0.0 24 | github.com/xeipuuv/gojsonschema v1.2.0 25 | golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 // indirect 26 | golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 // indirect 27 | golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ancientlore/go-avltree v1.0.1 h1:4XsGK6rkg1rjCTZoQbc09It5tmgMGCcFSYCVRwV99KU= 2 | github.com/ancientlore/go-avltree v1.0.1/go.mod h1:nfJ32Li6TWi3iVi9M3XF19FNqdfTlmoI87CPVgtgqoc= 3 | github.com/antchfx/jsonquery v1.1.4 h1:+OlFO3QS9wjU0MKx9MgHm5f6o6hdd4e9mUTp0wTjxlM= 4 | github.com/antchfx/jsonquery v1.1.4/go.mod h1:cHs8r6Bymd8j6HI6Ej1IJbjahKvLBcIEh54dfmo+E9A= 5 | github.com/antchfx/xpath v1.1.7 h1:RgnAdTaRzF4bBiTqdDA7ZQ7IU8ivc72KSTf3/XCA/ic= 6 | github.com/antchfx/xpath v1.1.7/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk= 7 | github.com/bhmj/jsonslice v0.0.0-20200507101114-bc37219df21b h1:jl6IPYFWFCMzuIctJXGSrZAHpbDZuEJ+xPh5WP5Ac88= 8 | github.com/bhmj/jsonslice v0.0.0-20200507101114-bc37219df21b/go.mod h1:blvNODZOz8uOvDJzGiXzoi8QlzcAhA57sMnKx1D18/k= 9 | github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= 10 | github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 11 | github.com/codegangsta/negroni v1.0.0 h1:+aYywywx4bnKXWvoWtRfJ91vC59NbEhEY03sZjQhbVY= 12 | github.com/codegangsta/negroni v1.0.0/go.mod h1:v0y3T5G7Y1UlFfyxFn/QLRU4a2EuNau2iZY63YTKWo0= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/dgrijalva/jwt-go v3.0.0+incompatible h1:nfVqwkkhaRUethVJaQf5TUFdFr3YUF4lJBTf/F2XwVI= 17 | github.com/dgrijalva/jwt-go v3.0.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 18 | github.com/eclipse/paho.mqtt.golang v1.2.0 h1:1F8mhG9+aO5/xpdtFkW4SxOJB67ukuDC3t2y2qayIX0= 19 | github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= 20 | github.com/evanphx/json-patch/v5 v5.1.0 h1:B0aXl1o/1cP8NbviYiBMkcHBtUjIJ1/Ccg6b+SwCLQg= 21 | github.com/evanphx/json-patch/v5 v5.1.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= 22 | github.com/farshidtz/elog v1.0.1 h1:GXdAmbHVyJ5l6V37gLK+Pedjd/sMRt0RPLzB/HcVFj0= 23 | github.com/farshidtz/elog v1.0.1/go.mod h1:OXTASC4gfW1KTSCg/qieXdyo9SoUA+c4U8qGkp6DW9I= 24 | github.com/farshidtz/mqtt-match v1.0.1 h1:VEBojQL9P5F7E3gu9XULIUZnzZknn7byF2rJ7RX20cQ= 25 | github.com/farshidtz/mqtt-match v1.0.1/go.mod h1:Kwf4JfzMhR3aPmVY5jqTkLXY/E3DgHsRlCUekyqdQHw= 26 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 27 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 28 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= 29 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 30 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 31 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 32 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w= 33 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 34 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= 35 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 36 | github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= 37 | github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 38 | github.com/grandcat/zeroconf v1.0.1-0.20200528163356-cfc8183341d9 h1:Vb1ObISmE870cPVbpX8SSaiJbSCXLxn9quYcmXRvN6Y= 39 | github.com/grandcat/zeroconf v1.0.1-0.20200528163356-cfc8183341d9/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= 40 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 41 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 42 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 43 | github.com/justinas/alice v0.0.0-20160512134231-052b8b6c18ed h1:Ab4XhecWusSSeIfQ2eySh7kffQ1Wsv6fNSkwefr6AVQ= 44 | github.com/justinas/alice v0.0.0-20160512134231-052b8b6c18ed/go.mod h1:oLH0CmIaxCGXD67VKGR5AacGXZSMznlmeqM8RzPrcY8= 45 | github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 46 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= 47 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 48 | github.com/linksmart/go-sec v1.0.1/go.mod h1:bTksBzP6fCEwIM43z8m3jSRa4YIAWdUwMBYjcoftm1c= 49 | github.com/linksmart/go-sec v1.4.2 h1:PhXpF6Gjm8/EYPUzoX0C8OJZ5FEOnS6XDtO8JHzu1hk= 50 | github.com/linksmart/go-sec v1.4.2/go.mod h1:W9EZRLqptioAzaxMjWEKzd5jye53aoRzMi4KO+FCFjY= 51 | github.com/linksmart/service-catalog/v3 v3.0.0-beta.1.0.20200302143206-92739dd2a511 h1:JNHuaKtZUDsgbGJ5bdFBZ4vIUlJB7EBvjLdSaNOFatQ= 52 | github.com/linksmart/service-catalog/v3 v3.0.0-beta.1.0.20200302143206-92739dd2a511/go.mod h1:2C0k5NvYvMgX2y095WCfuhpfZyKrZXX/TjYxlgR9K8g= 53 | github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= 54 | github.com/miekg/dns v1.1.29 h1:xHBEhR+t5RzcFJjBLJlax2daXOrTYtr9z4WdKEfWFzg= 55 | github.com/miekg/dns v1.1.29/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= 56 | github.com/oleksandr/bonjour v0.0.0-20160508152359-5dcf00d8b228/go.mod h1:MGuVJ1+5TX1SCoO2Sx0eAnjpdRytYla2uC1YIZfkC9c= 57 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 58 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 59 | github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU= 60 | github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg= 61 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 62 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 63 | github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg= 64 | github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= 65 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 66 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 67 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 68 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 69 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 70 | github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= 71 | github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= 72 | github.com/satori/go.uuid v1.1.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 73 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= 74 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 75 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 76 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 77 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 78 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 79 | github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= 80 | github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= 81 | github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= 82 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= 83 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= 84 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= 85 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= 86 | github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= 87 | github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= 88 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 89 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 90 | golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw= 91 | golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 92 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 93 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 94 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 95 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 96 | golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 97 | golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 98 | golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 h1:eDrdRpKgkcCqKZQwyZRyeFZgfqt37SL7Kv3tok06cKE= 99 | golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 100 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 101 | golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= 102 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 103 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 104 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 105 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 106 | golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 107 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 108 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 109 | golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc= 110 | golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 112 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 113 | golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 114 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 115 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbOeHJjicWYPqR9bpxqxYG2pA= 116 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 117 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 118 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 119 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 120 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 121 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 122 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 123 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 124 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 125 | gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= 126 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 127 | -------------------------------------------------------------------------------- /init.go: -------------------------------------------------------------------------------- 1 | 2 | package main 3 | 4 | import ( 5 | "log" 6 | "os" 7 | ) 8 | 9 | const ( 10 | EnvVerbose = "VERBOSE" // print extra information e.g. line number) 11 | EnvDisableLogTime = "DISABLE_LOG_TIME" // disable timestamp in logs 12 | ) 13 | 14 | func init() { 15 | log.SetOutput(os.Stdout) 16 | log.SetFlags(0) 17 | 18 | logFlags := log.LstdFlags 19 | if evalEnv(EnvDisableLogTime) { 20 | logFlags = 0 21 | } 22 | if evalEnv(EnvVerbose) { 23 | logFlags = logFlags | log.Lshortfile 24 | } 25 | log.SetFlags(logFlags) 26 | } 27 | 28 | // evalEnv returns the boolean value of the env variable with the given key 29 | func evalEnv(key string) bool { 30 | return os.Getenv(key) == "1" || os.Getenv(key) == "true" || os.Getenv(key) == "TRUE" 31 | } 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | 13 | "github.com/codegangsta/negroni" 14 | "github.com/gorilla/context" 15 | "github.com/justinas/alice" 16 | _ "github.com/linksmart/go-sec/auth/keycloak/obtainer" 17 | _ "github.com/linksmart/go-sec/auth/keycloak/validator" 18 | "github.com/linksmart/go-sec/auth/validator" 19 | "github.com/rs/cors" 20 | uuid "github.com/satori/go.uuid" 21 | "github.com/tinyiot/thing-directory/catalog" 22 | "github.com/tinyiot/thing-directory/notification" 23 | "github.com/tinyiot/thing-directory/wot" 24 | ) 25 | 26 | const TinyIoT = ` 27 | ▀█▀ █ █▄░█ █▄█   █ █▀█ ▀█▀ 28 | ░█░ █ █░▀█ ░█░   █ █▄█ ░█░ 29 | ` 30 | 31 | const ( 32 | SwaggerUISchemeLess = "petstore.swagger.io" 33 | Spec = "https://raw.githubusercontent.com/tinyiot/thing-directory/{version}/apidoc/openapi-spec.yml" 34 | SourceCodeRepo = "https://github.com/tinyiot/thing-directory" 35 | ) 36 | 37 | var ( 38 | confPath = flag.String("conf", "conf/thing-directory.json", "Configuration file path") 39 | version = flag.Bool("version", false, "Print the API version") 40 | Version string // set with build flags 41 | BuildNumber string // set with build flags 42 | ) 43 | 44 | func main() { 45 | flag.Parse() 46 | if *version { 47 | fmt.Println(Version) 48 | return 49 | } 50 | 51 | fmt.Print(TinyIoT) 52 | log.Printf("Starting Thing Directory") 53 | defer log.Println("Stopped.") 54 | 55 | if Version != "" { 56 | log.Printf("Version: %s", Version) 57 | } 58 | if BuildNumber != "" { 59 | log.Printf("Build Number: %s", BuildNumber) 60 | } 61 | 62 | config, err := loadConfig(*confPath) 63 | if err != nil { 64 | panic("Error reading config file:" + err.Error()) 65 | } 66 | log.Printf("Loaded config file: %s", *confPath) 67 | if config.ServiceID == "" { 68 | config.ServiceID = uuid.NewV4().String() 69 | log.Printf("Service ID not set. Generated new UUID: %s", config.ServiceID) 70 | } 71 | 72 | if len(config.Validation.JSONSchemas) > 0 { 73 | err = wot.LoadJSONSchemas(config.Validation.JSONSchemas) 74 | if err != nil { 75 | panic("error loading validation JSON Schemas: " + err.Error()) 76 | } 77 | log.Printf("Loaded JSON Schemas: %v", config.Validation.JSONSchemas) 78 | } else { 79 | log.Printf("Warning: No configuration for JSON Schemas. TDs will not be validated.") 80 | } 81 | 82 | // Setup API storage 83 | var storage catalog.Storage 84 | switch config.Storage.Type { 85 | case catalog.BackendLevelDB: 86 | storage, err = catalog.NewLevelDBStorage(config.Storage.DSN, nil) 87 | if err != nil { 88 | panic("Failed to start LevelDB storage:" + err.Error()) 89 | } 90 | defer storage.Close() 91 | default: 92 | panic("Could not create catalog API storage. Unsupported type:" + config.Storage.Type) 93 | } 94 | 95 | controller, err := catalog.NewController(storage) 96 | if err != nil { 97 | panic("Failed to start the controller:" + err.Error()) 98 | } 99 | defer controller.Stop() 100 | 101 | // Create catalog API object 102 | api := catalog.NewHTTPAPI(controller, Version) 103 | 104 | // Start notification 105 | var eventQueue notification.EventQueue 106 | switch config.Storage.Type { 107 | case catalog.BackendLevelDB: 108 | eventQueue, err = notification.NewLevelDBEventQueue(config.Storage.DSN+"/sse", nil, 1000) 109 | if err != nil { 110 | panic("Failed to start LevelDB storage for SSE events:" + err.Error()) 111 | } 112 | defer eventQueue.Close() 113 | default: 114 | panic("Could not create SSE storage. Unsupported type:" + config.Storage.Type) 115 | } 116 | notificationController := notification.NewController(eventQueue) 117 | notifAPI := notification.NewSSEAPI(notificationController, Version) 118 | defer notificationController.Stop() 119 | 120 | controller.AddSubscriber(notificationController) 121 | 122 | nRouter, err := setupHTTPRouter(&config.HTTP, api, notifAPI) 123 | if err != nil { 124 | panic(err) 125 | } 126 | 127 | // Start listener 128 | addr := fmt.Sprintf("%s:%d", config.HTTP.BindAddr, config.HTTP.BindPort) 129 | listener, err := net.Listen("tcp", addr) 130 | if err != nil { 131 | panic(err) 132 | } 133 | 134 | go func() { 135 | if config.HTTP.TLSConfig.Enabled { 136 | log.Printf("HTTP/TLS server listening on %v", addr) 137 | log.Fatalf("Error starting HTTP/TLS Server: %s", http.ServeTLS(listener, nRouter, config.HTTP.TLSConfig.CertFile, config.HTTP.TLSConfig.KeyFile)) 138 | } else { 139 | log.Printf("HTTP server listening on %v", addr) 140 | log.Fatalf("Error starting HTTP Server: %s", http.Serve(listener, nRouter)) 141 | } 142 | }() 143 | 144 | // Publish service using DNS-SD 145 | if config.DNSSD.Publish.Enabled { 146 | shutdown, err := registerDNSSDService(config) 147 | if err != nil { 148 | log.Printf("Failed to register DNS-SD service: %s", err) 149 | } 150 | defer shutdown() 151 | } 152 | 153 | log.Println("Ready!") 154 | 155 | // Ctrl+C / Kill handling 156 | handler := make(chan os.Signal, 1) 157 | signal.Notify(handler, syscall.SIGINT, syscall.SIGTERM) 158 | <-handler 159 | log.Println("Shutting down...") 160 | } 161 | 162 | func setupHTTPRouter(config *HTTPConfig, api *catalog.HTTPAPI, notifAPI *notification.SSEAPI) (*negroni.Negroni, error) { 163 | 164 | corsHandler := cors.New(cors.Options{ 165 | AllowedOrigins: []string{"*"}, 166 | AllowedMethods: []string{ 167 | http.MethodHead, 168 | http.MethodGet, 169 | http.MethodPost, 170 | http.MethodPut, 171 | http.MethodPatch, 172 | http.MethodDelete, 173 | }, 174 | AllowedHeaders: []string{"*"}, 175 | AllowCredentials: false, 176 | ExposedHeaders: []string{"*"}, 177 | }) 178 | commonHandlers := alice.New( 179 | context.ClearHandler, 180 | corsHandler.Handler, 181 | ) 182 | 183 | // Append auth handler if enabled 184 | if config.Auth.Enabled { 185 | // Setup ticket validator 186 | v, err := validator.Setup( 187 | config.Auth.Provider, 188 | config.Auth.ProviderURL, 189 | config.Auth.ClientID, 190 | config.Auth.BasicEnabled, 191 | &config.Auth.Authz) 192 | if err != nil { 193 | return nil, err 194 | } 195 | 196 | commonHandlers = commonHandlers.Append(v.Handler) 197 | } 198 | 199 | // Configure http api router 200 | r := newRouter() 201 | r.get("/", commonHandlers.ThenFunc(indexHandler)) 202 | r.options("/{path:.*}", commonHandlers.ThenFunc(optionsHandler)) 203 | // OpenAPI Proxy for Swagger "try it out" feature 204 | r.get("/openapi-spec-proxy", commonHandlers.ThenFunc(apiSpecProxy)) 205 | r.get("/openapi-spec-proxy/{basepath:.+}", commonHandlers.ThenFunc(apiSpecProxy)) 206 | 207 | // Things API (CRUDL) 208 | r.post("/things", commonHandlers.ThenFunc(api.Post)) // create anonymous 209 | r.put("/things/{id:.+}", commonHandlers.ThenFunc(api.Put)) // create or update 210 | r.get("/things/{id:.+}", commonHandlers.ThenFunc(api.Get)) // retrieve 211 | r.patch("/things/{id:.+}", commonHandlers.ThenFunc(api.Patch)) // partially update 212 | r.delete("/things/{id:.+}", commonHandlers.ThenFunc(api.Delete)) // delete 213 | r.get("/things", commonHandlers.ThenFunc(api.List)) // listing 214 | 215 | // Search API 216 | r.get("/search/jsonpath", commonHandlers.ThenFunc(api.SearchJSONPath)) 217 | 218 | // Events API 219 | r.get("/events", commonHandlers.ThenFunc(notifAPI.SubscribeEvent)) 220 | r.get("/events/{type}", commonHandlers.ThenFunc(notifAPI.SubscribeEvent)) 221 | 222 | logger := negroni.NewLogger() 223 | logFlags := log.LstdFlags 224 | if evalEnv(EnvDisableLogTime) { 225 | logFlags = 0 226 | } 227 | logger.ALogger = log.New(os.Stdout, "", logFlags) 228 | logger.SetFormat("{{.Method}} {{.Request.URL}} {{.Request.Proto}} {{.Status}} {{.Duration}}") 229 | 230 | // Configure the middleware 231 | n := negroni.New( 232 | negroni.NewRecovery(), 233 | logger, 234 | ) 235 | // Mount router 236 | n.UseHandler(r) 237 | 238 | return n, nil 239 | } 240 | -------------------------------------------------------------------------------- /notification/controller.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | 8 | jsonpatch "github.com/evanphx/json-patch/v5" 9 | "github.com/tinyiot/thing-directory/catalog" 10 | "github.com/tinyiot/thing-directory/wot" 11 | ) 12 | 13 | type Controller struct { 14 | s EventQueue 15 | // Events are pushed to this channel by the main events-gathering routine 16 | Notifier chan Event 17 | 18 | // New client connections 19 | subscribingClients chan subscriber 20 | 21 | // Closed client connections 22 | unsubscribingClients chan chan Event 23 | 24 | // Client connections registry 25 | activeClients map[chan Event]subscriber 26 | 27 | // shutdown 28 | shutdown chan bool 29 | } 30 | 31 | type subscriber struct { 32 | client chan Event 33 | eventTypes []wot.EventType 34 | diff bool 35 | lastEventID string 36 | } 37 | 38 | func NewController(s EventQueue) *Controller { 39 | c := &Controller{ 40 | s: s, 41 | Notifier: make(chan Event, 1), 42 | subscribingClients: make(chan subscriber), 43 | unsubscribingClients: make(chan chan Event), 44 | activeClients: make(map[chan Event]subscriber), 45 | shutdown: make(chan bool), 46 | } 47 | go c.handler() 48 | return c 49 | } 50 | 51 | func (c *Controller) subscribe(client chan Event, eventTypes []wot.EventType, diff bool, lastEventID string) error { 52 | s := subscriber{client: client, 53 | eventTypes: eventTypes, 54 | diff: diff, 55 | lastEventID: lastEventID, 56 | } 57 | c.subscribingClients <- s 58 | return nil 59 | } 60 | 61 | func (c *Controller) unsubscribe(client chan Event) error { 62 | c.unsubscribingClients <- client 63 | return nil 64 | } 65 | 66 | func (c *Controller) storeAndNotify(event Event) error { 67 | var err error 68 | event.ID, err = c.s.getNewID() 69 | if err != nil { 70 | return fmt.Errorf("error generating ID : %v", err) 71 | } 72 | 73 | // Notify 74 | c.Notifier <- event 75 | 76 | // Store 77 | err = c.s.addRotate(event) 78 | if err != nil { 79 | return fmt.Errorf("error storing the notification : %v", err) 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func (c *Controller) Stop() { 86 | c.shutdown <- true 87 | } 88 | 89 | func (c *Controller) CreateHandler(new catalog.ThingDescription) error { 90 | event := Event{ 91 | Type: wot.EventTypeCreate, 92 | Data: new, 93 | } 94 | 95 | err := c.storeAndNotify(event) 96 | return err 97 | } 98 | 99 | func (c *Controller) UpdateHandler(old catalog.ThingDescription, new catalog.ThingDescription) error { 100 | oldJson, err := json.Marshal(old) 101 | if err != nil { 102 | return fmt.Errorf("error marshalling old TD") 103 | } 104 | newJson, err := json.Marshal(new) 105 | if err != nil { 106 | return fmt.Errorf("error marshalling new TD") 107 | } 108 | patch, err := jsonpatch.CreateMergePatch(oldJson, newJson) 109 | if err != nil { 110 | return fmt.Errorf("error merging new TD") 111 | } 112 | var td catalog.ThingDescription 113 | if err := json.Unmarshal(patch, &td); err != nil { 114 | return fmt.Errorf("error unmarshalling the patch TD") 115 | } 116 | td[wot.KeyThingID] = old[wot.KeyThingID] 117 | event := Event{ 118 | Type: wot.EventTypeUpdate, 119 | Data: td, 120 | } 121 | err = c.storeAndNotify(event) 122 | return err 123 | } 124 | 125 | func (c *Controller) DeleteHandler(old catalog.ThingDescription) error { 126 | deleted := catalog.ThingDescription{ 127 | wot.KeyThingID: old[wot.KeyThingID], 128 | } 129 | event := Event{ 130 | Type: wot.EventTypeDelete, 131 | Data: deleted, 132 | } 133 | err := c.storeAndNotify(event) 134 | return err 135 | } 136 | 137 | func (c *Controller) handler() { 138 | loop: 139 | for { 140 | select { 141 | case s := <-c.subscribingClients: 142 | c.activeClients[s.client] = s 143 | log.Printf("New subscription. %d active clients", len(c.activeClients)) 144 | 145 | // Send the missed events 146 | if s.lastEventID != "" { 147 | missedEvents, err := c.s.getAllAfter(s.lastEventID) 148 | if err != nil { 149 | log.Printf("error getting the events after ID %s: %s", s.lastEventID, err) 150 | continue loop 151 | } 152 | for _, event := range missedEvents { 153 | sendToSubscriber(s, event) 154 | } 155 | } 156 | case clientChan := <-c.unsubscribingClients: 157 | delete(c.activeClients, clientChan) 158 | close(clientChan) 159 | log.Printf("Unsubscribed. %d active clients", len(c.activeClients)) 160 | case event := <-c.Notifier: 161 | for _, s := range c.activeClients { 162 | sendToSubscriber(s, event) 163 | } 164 | case <-c.shutdown: 165 | log.Println("Shutting down notification controller") 166 | break loop 167 | } 168 | } 169 | 170 | } 171 | 172 | func sendToSubscriber(s subscriber, event Event) { 173 | for _, eventType := range s.eventTypes { 174 | // Send the notification if the type matches 175 | if eventType == event.Type { 176 | toSend := event 177 | if !s.diff { 178 | toSend.Data = catalog.ThingDescription{wot.KeyThingID: toSend.Data[wot.KeyThingID]} 179 | } 180 | s.client <- toSend 181 | break 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /notification/ldb_eventqueue.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "encoding/binary" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "net/url" 10 | "strconv" 11 | "sync" 12 | 13 | "github.com/syndtr/goleveldb/leveldb" 14 | "github.com/syndtr/goleveldb/leveldb/opt" 15 | "github.com/syndtr/goleveldb/leveldb/util" 16 | ) 17 | 18 | // LevelDB storage 19 | type LevelDBEventQueue struct { 20 | db *leveldb.DB 21 | wg sync.WaitGroup 22 | latestID uint64 23 | capacity uint64 24 | } 25 | 26 | func NewLevelDBEventQueue(dsn string, opts *opt.Options, capacity uint64) (EventQueue, error) { 27 | url, err := url.Parse(dsn) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | // Open the database file 33 | db, err := leveldb.OpenFile(url.Path, opts) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | ldbEventQueue := &LevelDBEventQueue{db: db, capacity: capacity} 39 | ldbEventQueue.latestID, err = ldbEventQueue.fetchLatestID() 40 | if err != nil { 41 | return nil, fmt.Errorf("error fetching the latest ID from storage: %w", err) 42 | } 43 | return ldbEventQueue, nil 44 | } 45 | 46 | func (s *LevelDBEventQueue) addRotate(event Event) error { 47 | s.wg.Add(1) 48 | defer s.wg.Done() 49 | 50 | // add new data 51 | bytes, err := json.Marshal(event) 52 | if err != nil { 53 | return fmt.Errorf("error marshalling event: %w", err) 54 | } 55 | uintID, err := strconv.ParseUint(event.ID, 16, 64) 56 | if err != nil { 57 | return fmt.Errorf("error parsing event ID: %w", err) 58 | } 59 | batch := new(leveldb.Batch) 60 | 61 | batch.Put(uint64ToByte(uintID), bytes) 62 | 63 | // cleanup the older data 64 | if s.latestID > s.capacity { 65 | cleanBefore := s.latestID - s.capacity + 1 // adding 1 as Range is is not inclusive the limit. 66 | iter := s.db.NewIterator(&util.Range{Limit: uint64ToByte(cleanBefore)}, nil) 67 | for iter.Next() { 68 | // log.Println("deleting older entry: ", byteToUint64(iter.Key())) 69 | batch.Delete(iter.Key()) 70 | } 71 | iter.Release() 72 | err = iter.Error() 73 | if err != nil { 74 | return err 75 | } 76 | } 77 | err = s.db.Write(batch, nil) 78 | if err != nil { 79 | return fmt.Errorf("error cleaning up: %w", err) 80 | } 81 | return nil 82 | } 83 | 84 | func (s *LevelDBEventQueue) getAllAfter(id string) ([]Event, error) { 85 | intID, err := strconv.ParseUint(id, 16, 64) 86 | if err != nil { 87 | return nil, fmt.Errorf("error parsing latest ID: %w", err) 88 | } 89 | 90 | // start from the last missing event. 91 | // If the leveldb does not have the requested ID, 92 | // then the iterator starts with oldest available entry 93 | iter := s.db.NewIterator(&util.Range{Start: uint64ToByte(intID + 1)}, nil) 94 | var events []Event 95 | for iter.Next() { 96 | var event Event 97 | err = json.Unmarshal(iter.Value(), &event) 98 | if err != nil { 99 | iter.Release() 100 | return nil, fmt.Errorf("error unmarshalling event: %w", err) 101 | } 102 | events = append(events, event) 103 | } 104 | iter.Release() 105 | err = iter.Error() 106 | if err != nil { 107 | return nil, err 108 | } 109 | return events, nil 110 | } 111 | 112 | func (s *LevelDBEventQueue) getNewID() (string, error) { 113 | s.latestID += 1 114 | return strconv.FormatUint(s.latestID, 16), nil 115 | } 116 | 117 | func (s *LevelDBEventQueue) Close() { 118 | s.wg.Wait() 119 | err := s.db.Close() 120 | if err != nil { 121 | log.Printf("Error closing SSE storage: %s", err) 122 | } 123 | if flag.Lookup("test.v") == nil { 124 | log.Println("Closed SSE leveldb.") 125 | } 126 | } 127 | 128 | func (s *LevelDBEventQueue) fetchLatestID() (uint64, error) { 129 | var latestID uint64 130 | s.wg.Add(1) 131 | defer s.wg.Done() 132 | iter := s.db.NewIterator(nil, nil) 133 | exists := iter.Last() 134 | if exists { 135 | latestID = byteToUint64(iter.Key()) 136 | } else { 137 | // Start from 0 138 | latestID = 0 139 | } 140 | iter.Release() 141 | err := iter.Error() 142 | if err != nil { 143 | return 0, err 144 | } 145 | return latestID, nil 146 | } 147 | 148 | //byte to unint64 conversion functions and vice versa 149 | func byteToUint64(input []byte) uint64 { 150 | return binary.BigEndian.Uint64(input) 151 | } 152 | func uint64ToByte(input uint64) []byte { 153 | output := make([]byte, 8) 154 | binary.BigEndian.PutUint64(output, input) 155 | return output 156 | } 157 | -------------------------------------------------------------------------------- /notification/notification.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "github.com/tinyiot/thing-directory/catalog" 5 | "github.com/tinyiot/thing-directory/wot" 6 | ) 7 | 8 | type Event struct { 9 | ID string `json:"id"` 10 | Type wot.EventType `json:"event"` 11 | Data catalog.ThingDescription `json:"data"` 12 | } 13 | 14 | // NotificationController interface 15 | type NotificationController interface { 16 | // subscribe to the events. the caller will get events through the channel 'client' starting from 'lastEventID' 17 | subscribe(client chan Event, eventTypes []wot.EventType, diff bool, lastEventID string) error 18 | 19 | // unsubscribe and close the channel 'client' 20 | unsubscribe(client chan Event) error 21 | 22 | // Stop the controller 23 | Stop() 24 | 25 | catalog.EventListener 26 | } 27 | 28 | // EventQueue interface 29 | type EventQueue interface { 30 | //addRotate adds new and delete the old event if the event queue is full 31 | addRotate(event Event) error 32 | 33 | // getAllAfter gets the events after the event ID 34 | getAllAfter(id string) ([]Event, error) 35 | 36 | // getNewID creates a new ID for the event 37 | getNewID() (string, error) 38 | 39 | // Close all the resources acquired by the queue implementation 40 | Close() 41 | } 42 | -------------------------------------------------------------------------------- /notification/sse.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/gorilla/mux" 11 | "github.com/tinyiot/thing-directory/catalog" 12 | "github.com/tinyiot/thing-directory/wot" 13 | ) 14 | 15 | const ( 16 | QueryParamType = "type" 17 | QueryParamFull = "diff" 18 | HeaderLastEventID = "Last-Event-ID" 19 | ) 20 | 21 | type SSEAPI struct { 22 | controller NotificationController 23 | contentType string 24 | } 25 | 26 | func NewSSEAPI(controller NotificationController, version string) *SSEAPI { 27 | contentType := "text/event-stream" 28 | if version != "" { 29 | contentType += ";version=" + version 30 | } 31 | return &SSEAPI{ 32 | controller: controller, 33 | contentType: contentType, 34 | } 35 | 36 | } 37 | 38 | func (a *SSEAPI) SubscribeEvent(w http.ResponseWriter, req *http.Request) { 39 | diff, err := parseQueryParameters(req) 40 | if err != nil { 41 | catalog.ErrorResponse(w, http.StatusBadRequest, err) 42 | return 43 | } 44 | eventTypes, err := parsePath(req) 45 | if err != nil { 46 | catalog.ErrorResponse(w, http.StatusBadRequest, err) 47 | return 48 | } 49 | flusher, ok := w.(http.Flusher) 50 | if !ok { 51 | catalog.ErrorResponse(w, http.StatusInternalServerError, "Streaming unsupported") 52 | return 53 | } 54 | w.Header().Set("Content-Type", a.contentType) 55 | 56 | messageChan := make(chan Event) 57 | 58 | lastEventID := req.Header.Get(HeaderLastEventID) 59 | a.controller.subscribe(messageChan, eventTypes, diff, lastEventID) 60 | 61 | go func() { 62 | <-req.Context().Done() 63 | // unsubscribe to events and close the messageChan 64 | a.controller.unsubscribe(messageChan) 65 | }() 66 | 67 | for event := range messageChan { 68 | //data, err := json.MarshalIndent(event.Data, "data: ", "") 69 | data, err := json.Marshal(event.Data) 70 | if err != nil { 71 | log.Printf("error marshaling event %v: %s", event, err) 72 | } 73 | fmt.Fprintf(w, "event: %s\n", event.Type) 74 | fmt.Fprintf(w, "id: %s\n", event.ID) 75 | fmt.Fprintf(w, "data: %s\n\n", data) 76 | 77 | flusher.Flush() 78 | } 79 | } 80 | 81 | func parseQueryParameters(req *http.Request) (bool, error) { 82 | diff := false 83 | req.ParseForm() 84 | // Parse diff or just ID 85 | if strings.EqualFold(req.Form.Get(QueryParamFull), "true") { 86 | diff = true 87 | } 88 | return diff, nil 89 | } 90 | 91 | func parsePath(req *http.Request) ([]wot.EventType, error) { 92 | // Parse event type to be subscribed to 93 | params := mux.Vars(req) 94 | event := params[QueryParamType] 95 | if event == "" { 96 | return []wot.EventType{wot.EventTypeCreate, wot.EventTypeUpdate, wot.EventTypeDelete}, nil 97 | } 98 | 99 | eventType := wot.EventType(event) 100 | if !eventType.IsValid() { 101 | return nil, fmt.Errorf("invalid type in path") 102 | } 103 | 104 | return []wot.EventType{eventType}, nil 105 | 106 | } 107 | -------------------------------------------------------------------------------- /router.go: -------------------------------------------------------------------------------- 1 | 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "html/template" 7 | "io" 8 | "log" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/gorilla/mux" 13 | ) 14 | 15 | type router struct { 16 | *mux.Router 17 | } 18 | 19 | func newRouter() *router { 20 | return &router{mux.NewRouter().StrictSlash(false).SkipClean(true)} 21 | } 22 | 23 | func (r *router) get(path string, handler http.Handler) { 24 | r.Methods("GET").Path(path).Handler(handler) 25 | r.Methods("GET").Path(fmt.Sprintf("%s/", path)).Handler(handler) 26 | } 27 | 28 | func (r *router) post(path string, handler http.Handler) { 29 | r.Methods("POST").Path(path).Handler(handler) 30 | r.Methods("POST").Path(fmt.Sprintf("%s/", path)).Handler(handler) 31 | } 32 | 33 | func (r *router) put(path string, handler http.Handler) { 34 | r.Methods("PUT").Path(path).Handler(handler) 35 | r.Methods("PUT").Path(fmt.Sprintf("%s/", path)).Handler(handler) 36 | } 37 | 38 | func (r *router) delete(path string, handler http.Handler) { 39 | r.Methods("DELETE").Path(path).Handler(handler) 40 | r.Methods("DELETE").Path(fmt.Sprintf("%s/", path)).Handler(handler) 41 | } 42 | 43 | func (r *router) patch(path string, handler http.Handler) { 44 | r.Methods("PATCH").Path(path).Handler(handler) 45 | r.Methods("PATCH").Path(fmt.Sprintf("%s/", path)).Handler(handler) 46 | } 47 | 48 | func (r *router) head(path string, handler http.Handler) { 49 | r.Methods("HEAD").Path(path).Handler(handler) 50 | r.Methods("HEAD").Path(fmt.Sprintf("%s/", path)).Handler(handler) 51 | } 52 | 53 | func (r *router) options(path string, handler http.Handler) { 54 | r.Methods("OPTIONS").Path(path).Handler(handler) 55 | r.Methods("OPTIONS").Path(fmt.Sprintf("%s/", path)).Handler(handler) 56 | } 57 | 58 | func optionsHandler(w http.ResponseWriter, _ *http.Request) { 59 | w.WriteHeader(http.StatusOK) 60 | } 61 | 62 | func indexHandler(w http.ResponseWriter, _ *http.Request) { 63 | version := "master" 64 | if Version != "" { 65 | version = Version 66 | } 67 | spec := strings.NewReplacer("{version}", version).Replace(Spec) 68 | 69 | swaggerUIRelativeScheme := "//" + SwaggerUISchemeLess 70 | swaggerUISecure := "https:" + swaggerUIRelativeScheme 71 | 72 | w.Header().Set("Content-Type", "text/html") 73 | 74 | data := struct { 75 | Logo, Version, SourceRepo, Spec, SwaggerUIRelativeScheme, SwaggerUISecure string 76 | }{TinyIoT, version, SourceCodeRepo, spec, swaggerUIRelativeScheme, swaggerUISecure} 77 | 78 | tmpl := ` 79 | 80 | 81 |
{{.Logo}}
82 |

Thing Directory

83 |

Version: {{.Version}}

84 |

{{.SourceRepo}}

85 |

API Documentation: Swagger UI

86 |

Try it out! (experimental; requires internet connection on both server and client sides)

87 | ` 92 | 93 | t, err := template.New("body").Parse(tmpl) 94 | if err != nil { 95 | log.Fatalf("Error parsing template: %s", err) 96 | } 97 | err = t.Execute(w, data) 98 | if err != nil { 99 | log.Fatalf("Error applying template to response: %s", err) 100 | } 101 | } 102 | 103 | func apiSpecProxy(w http.ResponseWriter, req *http.Request) { 104 | version := "master" 105 | if Version != "" { 106 | version = Version 107 | } 108 | spec := strings.NewReplacer("{version}", version).Replace(Spec) 109 | 110 | // get the spec 111 | res, err := http.Get(spec) 112 | if err != nil { 113 | w.WriteHeader(http.StatusInternalServerError) 114 | _, err := fmt.Fprintf(w, "Error querying Open API specs: %s", err) 115 | if err != nil { 116 | log.Printf("ERROR writing HTTP response: %s", err) 117 | } 118 | return 119 | } 120 | defer res.Body.Close() 121 | 122 | if res.StatusCode != http.StatusOK { 123 | w.WriteHeader(res.StatusCode) 124 | _, err := fmt.Fprintf(w, "GET %s: %s", spec, res.Status) 125 | if err != nil { 126 | log.Printf("ERROR writing HTTP response: %s", err) 127 | } 128 | return 129 | } 130 | 131 | // write the spec as response 132 | _, err = io.Copy(w, res.Body) 133 | if err != nil { 134 | w.WriteHeader(http.StatusInternalServerError) 135 | _, err := fmt.Fprintf(w, "Error responding Open API specs: %s", err) 136 | if err != nil { 137 | log.Printf("ERROR writing HTTP response: %s", err) 138 | } 139 | return 140 | } 141 | 142 | // append basename as server URL to api specs 143 | params := mux.Vars(req) 144 | if params["basepath"] != "" { 145 | basePath := strings.TrimSuffix(params["basepath"], "/") 146 | if !strings.HasPrefix(basePath, "/") { 147 | basePath = "/" + basePath 148 | } 149 | _, err := w.Write([]byte("\nservers: [url: " + basePath + "]")) 150 | if err != nil { 151 | log.Printf("ERROR writing HTTP response: %s", err) 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /sample_conf/thing-directory.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "TinyIoT Thing Directory", 3 | "validation": { 4 | "jsonSchemas": [] 5 | }, 6 | "storage": { 7 | "type": "leveldb", 8 | "dsn": "./data" 9 | }, 10 | "dnssd": { 11 | "publish": { 12 | "enabled": false, 13 | "instance": "TinyIoT Thing Directory", 14 | "domain": "local.", 15 | "interfaces": [] 16 | } 17 | }, 18 | "http": { 19 | "publicEndpoint": "http://fqdn-of-the-host:8081", 20 | "bindAddr": "0.0.0.0", 21 | "bindPort": 8081, 22 | "tls": { 23 | "enabled": false, 24 | "keyFile": "./tls/key.pem", 25 | "certFile": "./tls/cert.pem" 26 | }, 27 | "auth": { 28 | "enabled": false, 29 | "provider": "keycloak", 30 | "providerURL": "https://provider-url", 31 | "clientID": "sampleTD", 32 | "basicEnabled": false, 33 | "authorization": { 34 | "enabled": false, 35 | "rules": [ 36 | { 37 | "paths": ["/td"], 38 | "methods": ["GET","POST", "PUT", "DELETE"], 39 | "users": ["admin"], 40 | "groups": [], 41 | "roles": [], 42 | "clients": [], 43 | "excludePathSubstrings": [] 44 | }, 45 | { 46 | "paths": ["/td"], 47 | "methods": ["GET"], 48 | "users": [], 49 | "groups": ["anonymous"], 50 | "roles": [], 51 | "clients": [], 52 | "excludePathSubstrings": [] 53 | } 54 | ] 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: thing-directory 2 | base: core20 3 | 4 | # take the version from part 5 | adopt-info: thing-directory 6 | 7 | summary: A W3C WoT Thing Description Directory 8 | description: | 9 | This is a lightweight implementation of a WoT Thing Description Directory, 10 | described by the [W3C Discovery](https://w3c.github.io/wot-discovery/) standard specification. 11 | 12 | grade: stable 13 | confinement: strict 14 | 15 | architectures: 16 | - build-on: amd64 17 | - build-on: arm64 18 | - build-on: armhf 19 | 20 | apps: 21 | thing-directory: 22 | command: bin/thing-directory -conf $SNAP/conf/thing-directory.json 23 | environment: 24 | TD_VALIDATION_JSONSCHEMAS: "$SNAP/conf/wot_td_schema.json,$SNAP/conf/wot_discovery_schema.json" 25 | daemon: simple 26 | plugs: 27 | - network-bind 28 | 29 | parts: 30 | thing-directory: 31 | plugin: go 32 | source: . 33 | build-packages: 34 | - curl 35 | - git 36 | override-pull: | 37 | snapcraftctl pull 38 | snapcraftctl set-version $(git describe --tags) 39 | override-prime: | 40 | mkdir -p conf 41 | curl https://raw.githubusercontent.com/w3c/wot-thing-description/REC1.0/validation/td-json-schema-validation.json -o conf/wot_td_schema.json 42 | curl https://raw.githubusercontent.com/w3c/wot-discovery/main/validation/td-discovery-extensions-json-schema.json -o conf/wot_discovery_schema.json 43 | cp $SNAPCRAFT_PART_SRC/sample_conf/thing-directory.json conf/thing-directory.json 44 | snapcraftctl prime 45 | -------------------------------------------------------------------------------- /wot/discovery.go: -------------------------------------------------------------------------------- 1 | package wot 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | const ( 8 | // DNS-SD Types 9 | DNSSDServiceType = "_wot._tcp" 10 | DNSSDServiceSubtypeThing = "_thing" // _thing._sub._wot._tcp 11 | DNSSDServiceSubtypeDirectory = "_directory" // _directory._sub._wot._tcp 12 | // Media Types 13 | MediaTypeJSONLD = "application/ld+json" 14 | MediaTypeJSON = "application/json" 15 | // TD keys used by directory 16 | KeyThingID = "id" 17 | KeyThingRegistration = "registration" 18 | KeyThingRegistrationCreated = "created" 19 | KeyThingRegistrationModified = "modified" 20 | KeyThingRegistrationExpires = "expires" 21 | KeyThingRegistrationTTL = "ttl" 22 | // TD event types 23 | EventTypeCreate = "thing_created" 24 | EventTypeUpdate = "thing_updated" 25 | EventTypeDelete = "thing_deleted" 26 | ) 27 | 28 | type EnrichedTD struct { 29 | *ThingDescription 30 | Registration *ThingRegistration `json:"registration,omitempty"` 31 | } 32 | 33 | // ThingRegistration contains the registration information 34 | // alphabetically sorted to match the TD map serialization 35 | type ThingRegistration struct { 36 | Created *time.Time `json:"created,omitempty"` 37 | Expires *time.Time `json:"expires,omitempty"` 38 | Modified *time.Time `json:"modified,omitempty"` 39 | Retrieved *time.Time `json:"retrieved,omitempty"` 40 | TTL *float64 `json:"ttl,omitempty"` 41 | } 42 | 43 | type EventType string 44 | 45 | func (e EventType) IsValid() bool { 46 | switch e { 47 | case EventTypeCreate, EventTypeUpdate, EventTypeDelete: 48 | return true 49 | default: 50 | return false 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /wot/discovery_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "WoT Discovery TD-extensions Schema - 21 May 2021", 3 | "description": "JSON Schema for validating TD instances with WoT Discovery extensions", 4 | "$schema ": "http://json-schema.org/draft/2019-09/schema#", 5 | "type": "object", 6 | "properties": { 7 | "registration": { 8 | "type": "object", 9 | "properties": { 10 | "created": { 11 | "type": "string", 12 | "format": "date-time" 13 | }, 14 | "expires": { 15 | "type": "string", 16 | "format": "date-time" 17 | }, 18 | "retrieved": { 19 | "type": "string", 20 | "format": "date-time" 21 | }, 22 | "modified": { 23 | "type": "string", 24 | "format": "date-time" 25 | }, 26 | "ttl": { 27 | "type": "number" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /wot/error.go: -------------------------------------------------------------------------------- 1 | package wot 2 | 3 | // RFC7807 Problem Details (https://tools.ietf.org/html/rfc7807) 4 | type ProblemDetails struct { 5 | // Type A URI reference (RFC3986) that identifies the problem type. This specification encourages that, when 6 | // dereferenced, it provide human-readable documentation for the problem type (e.g., using HTML). When 7 | // this member is not present, its value is assumed to be "about:blank". 8 | Type string `json:"type,omitempty"` 9 | 10 | // Title - A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the 11 | // problem, except for purposes of localization (e.g., using proactive content negotiation); see RFC7231, Section 3.4. 12 | Title string `json:"title"` 13 | 14 | // Status - The HTTP status code (RFC7231, Section 6) generated by the origin server for this occurrence of the problem. 15 | Status int `json:"status"` 16 | 17 | // Detail - A human-readable explanation specific to this occurrence of the problem. 18 | Detail string `json:"detail"` 19 | 20 | // Instance - A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. 21 | Instance string `json:"instance,omitempty"` 22 | 23 | // ValidationErrors - Extension for detailed validation results 24 | ValidationErrors []ValidationError `json:"validationErrors,omitempty"` 25 | } 26 | 27 | type ValidationError struct { 28 | Field string `json:"field"` 29 | Descr string `json:"description"` 30 | } 31 | -------------------------------------------------------------------------------- /wot/thing_description.go: -------------------------------------------------------------------------------- 1 | package wot 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | const MediaTypeThingDescription = "application/td+json" 8 | 9 | /* 10 | This file has go models for Web Of Things (WoT) Things Description following : https://www.w3.org/TR/2019/CR-wot-thing-description-20191106/ (W3C Candidate Recommendation 6 November 2019) 11 | */ 12 | 13 | type any = interface{} 14 | 15 | // ThingDescription is the structured data describing a Thing 16 | type ThingDescription struct { 17 | 18 | // JSON-LD keyword to define short-hand names called terms that are used throughout a TD document. 19 | Context any `json:"@context"` 20 | 21 | // JSON-LD keyword to label the object with semantic tags (or types). 22 | Type any `json:"@type,omitempty"` 23 | 24 | // Identifier of the Thing in form of a URI [RFC3986] (e.g., stable URI, temporary and mutable URI, URI with local IP address, URN, etc.). 25 | ID AnyURI `json:"id,omitempty"` 26 | 27 | // Provides a human-readable title (e.g., display a text for UI representation) based on a default language. 28 | Title string `json:"title"` 29 | 30 | // Provides multi-language human-readable titles (e.g., display a text for UI representation in different languages). 31 | Titles map[string]string `json:"titles,omitempty"` 32 | 33 | // Provides additional (human-readable) information based on a default language 34 | Description string `json:"description,omitempty"` 35 | 36 | // Can be used to support (human-readable) information in different languages. 37 | Descriptions map[string]string `json:"descriptions,omitempty"` 38 | 39 | // Provides version information. 40 | Version *VersionInfo `json:"version,omitempty"` 41 | 42 | // Provides information when the TD instance was created. 43 | Created time.Time `json:"created,omitempty"` 44 | 45 | // Provides information when the TD instance was last modified. 46 | Modified time.Time `json:"modified,omitempty"` 47 | 48 | // Provides information about the TD maintainer as URI scheme (e.g., mailto [RFC6068], tel [RFC3966], https). 49 | Support AnyURI `json:"support,omitempty"` 50 | 51 | /* 52 | Define the base URI that is used for all relative URI references throughout a TD document. In TD instances, all relative URIs are resolved relative to the base URI using the algorithm defined in [RFC3986]. 53 | 54 | base does not affect the URIs used in @context and the IRIs used within Linked Data [LINKED-DATA] graphs that are relevant when semantic processing is applied to TD instances. 55 | */ 56 | Base string `json:"base,omitempty"` 57 | 58 | // All Property-based Interaction Affordances of the Thing. 59 | Properties map[string]PropertyAffordance `json:"properties,omitempty"` 60 | 61 | // All Action-based Interaction Affordances of the Thing. 62 | Actions map[string]ActionAffordance `json:"actions,omitempty"` 63 | 64 | // All Event-based Interaction Affordances of the Thing. 65 | Events map[string]EventAffordance `json:"events,omitempty"` 66 | 67 | // Provides Web links to arbitrary resources that relate to the specified Thing Description. 68 | Links []Link `json:"links,omitempty"` 69 | 70 | // Set of form hypermedia controls that describe how an operation can be performed. Forms are serializations of Protocol Bindings. In this version of TD, all operations that can be described at the Thing level are concerning how to interact with the Thing's Properties collectively at once. 71 | Forms []Form `json:"forms,omitempty"` 72 | 73 | // Set of security definition names, chosen from those defined in securityDefinitions. These must all be satisfied for access to resources 74 | Security any `json:"security"` 75 | 76 | // Set of named security configurations (definitions only). Not actually applied unless names are used in a security name-value pair. 77 | SecurityDefinitions map[string]SecurityScheme `json:"securityDefinitions"` 78 | } 79 | 80 | /*Metadata of a Thing that shows the possible choices to Consumers, thereby suggesting how Consumers may interact with the Thing. 81 | There are many types of potential affordances, but W3C WoT defines three types of Interaction Affordances: Properties, Actions, and Events.*/ 82 | type InteractionAffordance struct { 83 | // JSON-LD keyword to label the object with semantic tags (or types). 84 | Type any `json:"@type,omitempty"` 85 | 86 | // Provides a human-readable title (e.g., display a text for UI representation) based on a default language. 87 | Title string `json:"title,omitempty"` 88 | 89 | // Provides multi-language human-readable titles (e.g., display a text for UI representation in different languages). 90 | Titles map[string]string `json:"titles,omitempty"` 91 | 92 | // Provides additional (human-readable) information based on a default language 93 | Description string `json:"description,omitempty"` 94 | 95 | // Can be used to support (human-readable) information in different languages. 96 | Descriptions map[string]string `json:"descriptions,omitempty"` 97 | 98 | /* 99 | Set of form hypermedia controls that describe how an operation can be performed. Forms are serializations of Protocol Bindings. 100 | When a Form instance is within an ActionAffordance instance, the value assigned to op MUST be invokeaction. 101 | When a Form instance is within an EventAffordance instance, the value assigned to op MUST be either subscribeevent, unsubscribeevent, or both terms within an Array. 102 | When a Form instance is within a PropertyAffordance instance, the value assigned to op MUST be one of readproperty, writeproperty, observeproperty, unobserveproperty or an Array containing a combination of these terms. 103 | 104 | */ 105 | Forms []Form `json:"forms"` 106 | 107 | // Define URI template variables as collection based on DataSchema declarations. 108 | UriVariables map[string]DataSchema `json:"uriVariables,omitempty"` 109 | } 110 | 111 | /* 112 | An Interaction Affordance that exposes state of the Thing. This state can then be retrieved (read) and optionally updated (write). 113 | Things can also choose to make Properties observable by pushing the new state after a change. 114 | */ 115 | type PropertyAffordance struct { 116 | InteractionAffordance 117 | DataSchema 118 | Observable bool `json:"observable,omitempty"` 119 | } 120 | 121 | /* 122 | An Interaction Affordance that allows to invoke a function of the Thing, which manipulates state (e.g., toggling a lamp on or off) or triggers a process on the Thing (e.g., dim a lamp over time). 123 | */ 124 | type ActionAffordance struct { 125 | InteractionAffordance 126 | 127 | // Used to define the input data schema of the Action. 128 | Input DataSchema `json:"input,omitempty"` 129 | 130 | // Used to define the output data schema of the Action. 131 | Output DataSchema `json:"output,omitempty"` 132 | 133 | // Signals if the Action is safe (=true) or not. Used to signal if there is no internal state (cf. resource state) is changed when invoking an Action. In that case responses can be cached as example. 134 | Safe bool `json:"safe"` //default: false 135 | 136 | // Indicates whether the Action is idempotent (=true) or not. Informs whether the Action can be called repeatedly with the same result, if present, based on the same input. 137 | Idempotent bool `json:"idempotent"` //default: false 138 | } 139 | 140 | /* 141 | An Interaction Affordance that describes an event source, which asynchronously pushes event data to Consumers (e.g., overheating alerts). 142 | */ 143 | type EventAffordance struct { 144 | InteractionAffordance 145 | 146 | // Defines data that needs to be passed upon subscription, e.g., filters or message format for setting up Webhooks. 147 | Subscription DataSchema `json:"subscription,omitempty"` 148 | 149 | // Defines the data schema of the Event instance messages pushed by the Thing. 150 | Data DataSchema `json:"data,omitempty"` 151 | 152 | // Defines any data that needs to be passed to cancel a subscription, e.g., a specific message to remove a Webhook. 153 | Cancellation DataSchema `json:"optional,omitempty"` 154 | } 155 | 156 | /* 157 | A form can be viewed as a statement of "To perform an operation type operation on form context, make a request method request to submission target" where the optional form fields may further describe the required request. 158 | In Thing Descriptions, the form context is the surrounding Object, such as Properties, Actions, and Events or the Thing itself for meta-interactions. 159 | */ 160 | type Form struct { 161 | 162 | /* 163 | Indicates the semantic intention of performing the operation(s) described by the form. 164 | For example, the Property interaction allows get and set operations. 165 | The protocol binding may contain a form for the get operation and a different form for the set operation. 166 | The op attribute indicates which form is for which and allows the client to select the correct form for the operation required. 167 | op can be assigned one or more interaction verb(s) each representing a semantic intention of an operation. 168 | It can be one of: readproperty, writeproperty, observeproperty, unobserveproperty, invokeaction, subscribeevent, unsubscribeevent, readallproperties, writeallproperties, readmultipleproperties, or writemultipleproperties 169 | a. When a Form instance is within an ActionAffordance instance, the value assigned to op MUST be invokeaction. 170 | b. When a Form instance is within an EventAffordance instance, the value assigned to op MUST be either subscribeevent, unsubscribeevent, or both terms within an Array. 171 | c. When a Form instance is within a PropertyAffordance instance, the value assigned to op MUST be one of readproperty, writeproperty, observeproperty, unobserveproperty or an Array containing a combination of these terms. 172 | */ 173 | Op any `json:"op"` 174 | 175 | // Target IRI of a link or submission target of a form. 176 | Href AnyURI `json:"href"` 177 | 178 | // Assign a content type based on a media type (e.g., text/plain) and potential parameters (e.g., charset=utf-8) for the media type [RFC2046]. 179 | ContentType string `json:"contentType"` //default: "application/json" 180 | 181 | // Content coding values indicate an encoding transformation that has been or can be applied to a representation. Content codings are primarily used to allow a representation to be compressed or otherwise usefully transformed without losing the identity of its underlying media type and without loss of information. Examples of content coding include "gzip", "deflate", etc. . 182 | // Possible values for the contentCoding property can be found, e.g., in thttps://www.iana.org/assignments/http-parameters/http-parameters.xhtml#content-coding 183 | ContentCoding string `json:"contentCoding,omitempty"` 184 | 185 | // Indicates the exact mechanism by which an interaction will be accomplished for a given protocol when there are multiple options. 186 | // For example, for HTTP and Events, it indicates which of several available mechanisms should be used for asynchronous notifications such as long polling (longpoll), WebSub [websub] (websub), Server-Sent Events [eventsource] (sse). Please note that there is no restriction on the subprotocol selection and other mechanisms can also be announced by this subprotocol term. 187 | SubProtocol string `json:"subprotocol,omitempty"` 188 | 189 | // Set of security definition names, chosen from those defined in securityDefinitions. These must all be satisfied for access to resources. 190 | Security any `json:"security,omitempty"` 191 | 192 | // Set of authorization scope identifiers provided as an array. These are provided in tokens returned by an authorization server and associated with forms in order to identify what resources a client may access and how. The values associated with a form should be chosen from those defined in an OAuth2SecurityScheme active on that form. 193 | Scopes any `json:"scopes,omitempty"` 194 | 195 | // This optional term can be used if, e.g., the output communication metadata differ from input metadata (e.g., output contentType differ from the input contentType). The response name contains metadata that is only valid for the response messages. 196 | Response *ExpectedResponse `json:"response,omitempty"` 197 | } 198 | 199 | /* 200 | A link can be viewed as a statement of the form "link context has a relation type resource at link target", where the optional target attributes may further describe the resource. 201 | */ 202 | type Link struct { 203 | // Target IRI of a link or submission target of a form. 204 | Href AnyURI `json:"href"` 205 | 206 | // Target attribute providing a hint indicating what the media type (RFC2046) of the result of dereferencing the link should be. 207 | Type string `json:"type,omitempty"` 208 | 209 | // A link relation type identifies the semantics of a link. 210 | Rel string `json:"rel,omitempty"` 211 | 212 | // Overrides the link context (by default the Thing itself identified by its id) with the given URI or IRI. 213 | Anchor AnyURI `json:"anchor,omitempty"` 214 | } 215 | 216 | type SecurityScheme struct { 217 | // JSON-LD keyword to label the object with semantic tags (or types). 218 | Type any `json:"@type,omitempty"` 219 | 220 | // Identification of the security mechanism being configured. e.g. nosec, basic, cert, digest, bearer, pop, psk, public, oauth2, or apike 221 | Scheme string `json:"scheme"` 222 | 223 | // Provides additional (human-readable) information based on a default language 224 | Description string `json:"description,omitempty"` 225 | 226 | // Can be used to support (human-readable) information in different languages. 227 | Descriptions map[string]string `json:"descriptions,omitempty"` 228 | 229 | // URI of the proxy server this security configuration provides access to. If not given, the corresponding security configuration is for the endpoint. 230 | Proxy AnyURI `json:"proxy,omitempty"` 231 | 232 | *BasicSecurityScheme 233 | *DigestSecurityScheme 234 | *APIKeySecurityScheme 235 | *BearerSecurityScheme 236 | *CertSecurityScheme 237 | *PSKSecurityScheme 238 | *PublicSecurityScheme 239 | *PoPSecurityScheme 240 | *OAuth2SecurityScheme 241 | } 242 | 243 | type DataSchema struct { 244 | // JSON-LD keyword to label the object with semantic tags (or types) 245 | Type any `json:"@type,omitempty"` 246 | 247 | // Const corresponds to the JSON schema field "const". 248 | Const any `json:"const,omitempty"` 249 | 250 | // Provides multi-language human-readable titles (e.g., display a text for UI representation in different languages). 251 | Description string `json:"description,omitempty"` 252 | 253 | // Can be used to support (human-readable) information in different languages 254 | Descriptions string `json:"descriptions,omitempty"` 255 | 256 | // Restricted set of values provided as an array. 257 | Enum []any `json:"enum,omitempty"` 258 | 259 | // Allows validation based on a format pattern such as "date-time", "email", "uri", etc. (Also see below.) 260 | Format string `json:"format,omitempty"` 261 | 262 | // OneOf corresponds to the JSON schema field "oneOf". 263 | OneOf []DataSchema `json:"oneOf,omitempty"` 264 | 265 | // ReadOnly corresponds to the JSON schema field "readOnly". 266 | ReadOnly bool `json:"readOnly,omitempty"` 267 | 268 | // Provides a human-readable title (e.g., display a text for UI representation) based on a default language. 269 | Title string `json:"title,omitempty"` 270 | 271 | // Provides multi-language human-readable titles (e.g., display a text for UI representation in different languages). 272 | Titles []string `json:"titles,omitempty"` 273 | 274 | // Assignment of JSON-based data types compatible with JSON Schema (one of boolean, integer, number, string, object, array, or null). 275 | // DataType corresponds to the JSON schema field "type". 276 | DataType string `json:"type,omitempty"` 277 | 278 | // Unit corresponds to the JSON schema field "unit". 279 | Unit string `json:"unit,omitempty"` 280 | 281 | // Boolean value that is a hint to indicate whether a property interaction / value is write only (=true) or not (=false). 282 | WriteOnly bool `json:"writeOnly,omitempty"` 283 | 284 | // Metadata describing data of type Array. This Subclass is indicated by the value array assigned to type in DataSchema instances. 285 | *ArraySchema 286 | 287 | // Metadata describing data of type number. This Subclass is indicated by the value number assigned to type in DataSchema instances. 288 | *NumberSchema 289 | 290 | // Metadata describing data of type object. This Subclass is indicated by the value object assigned to type in DataSchema instances. 291 | *ObjectSchema 292 | } 293 | 294 | // DataSchemaTypeEnumValues are the allowed values allowed for DataSchema.DataType 295 | var DataSchemaDataTypeEnumValues = []string{ 296 | "boolean", 297 | "integer", 298 | "number", 299 | "string", 300 | "object", 301 | "array", 302 | "null", 303 | } 304 | 305 | type ArraySchema struct { 306 | // Used to define the characteristics of an array. 307 | Items any `json:"items,omitempty"` 308 | 309 | // Defines the maximum number of items that have to be in the array. 310 | MaxItems *int `json:"maxItems,omitempty"` 311 | 312 | // Defines the minimum number of items that have to be in the array. 313 | MinItems *int `json:"minItems,omitempty"` 314 | } 315 | 316 | //Specifies both float and double 317 | type NumberSchema struct { 318 | // Specifies a maximum numeric value. Only applicable for associated number or integer types. 319 | Maximum *any `json:"maximum,omitempty"` 320 | 321 | // Specifies a minimum numeric value. Only applicable for associated number or integer types. 322 | Minimum *any `json:"minimum,omitempty"` 323 | } 324 | 325 | type ObjectSchema struct { 326 | // Data schema nested definitions. 327 | Properties map[string]DataSchema `json:"properties,omitempty"` 328 | 329 | // Required corresponds to the JSON schema field "required". 330 | Required []string `json:"required,omitempty"` 331 | } 332 | 333 | type AnyURI = string 334 | 335 | /* 336 | Communication metadata describing the expected response message. 337 | */ 338 | type ExpectedResponse struct { 339 | ContentType string `json:"contentType,omitempty"` 340 | } 341 | 342 | /* 343 | Metadata of a Thing that provides version information about the TD document. If required, additional version information such as firmware and hardware version (term definitions outside of the TD namespace) can be extended via the TD Context Extension mechanism. 344 | It is recommended that the values within instances of the VersionInfo Class follow the semantic versioning pattern (https://semver.org/), where a sequence of three numbers separated by a dot indicates the major version, minor version, and patch version, respectively. 345 | */ 346 | type VersionInfo struct { 347 | // Provides a version indicator of this TD instance. 348 | Instance string `json:"instance"` 349 | } 350 | 351 | /* 352 | Basic Authentication [RFC7617] security configuration identified by the Vocabulary Term basic (i.e., "scheme": "basic"), using an unencrypted username and password. 353 | This scheme should be used with some other security mechanism providing confidentiality, for example, TLS. 354 | */ 355 | type BasicSecurityScheme struct { 356 | // Specifies the location of security authentication information. 357 | In string `json:"in"` // default: header 358 | 359 | // Name for query, header, or cookie parameters. 360 | Name string `json:"name,omitempty"` 361 | } 362 | 363 | /* 364 | Digest Access Authentication [RFC7616] security configuration identified by the Vocabulary Term digest (i.e., "scheme": "digest"). 365 | This scheme is similar to basic authentication but with added features to avoid man-in-the-middle attacks. 366 | */ 367 | type DigestSecurityScheme struct { 368 | // Specifies the location of security authentication information. 369 | In string `json:"in"` // default: header 370 | 371 | // Name for query, header, or cookie parameters. 372 | Name string `json:"name,omitempty"` 373 | 374 | //Quality of protection. 375 | Qop string `json:"qop,omitempty"` //default: auth 376 | } 377 | 378 | /* 379 | API key authentication security configuration identified by the Vocabulary Term apikey (i.e., "scheme": "apikey"). 380 | This is for the case where the access token is opaque and is not using a standard token format. 381 | */ 382 | type APIKeySecurityScheme struct { 383 | // Specifies the location of security authentication information. 384 | In string `json:"in"` // default: header 385 | 386 | // Name for query, header, or cookie parameters. 387 | Name string `json:"name,omitempty"` 388 | } 389 | 390 | /* 391 | Bearer Token [RFC6750] security configuration identified by the Vocabulary Term bearer (i.e., "scheme": "bearer") for situations where bearer tokens are used independently of OAuth2. If the oauth2 scheme is specified it is not generally necessary to specify this scheme as well as it is implied. For format, the value jwt indicates conformance with [RFC7519], jws indicates conformance with [RFC7797], cwt indicates conformance with [RFC8392], and jwe indicates conformance with [RFC7516], with values for alg interpreted consistently with those standards. 392 | Other formats and algorithms for bearer tokens MAY be specified in vocabulary extensions 393 | */ 394 | type BearerSecurityScheme struct { 395 | // Specifies the location of security authentication information. 396 | In string `json:"in"` // default: header 397 | 398 | // Name for query, header, or cookie parameters. 399 | Name string `json:"name,omitempty"` 400 | 401 | // URI of the authorization server. 402 | Authorization AnyURI `json:"authorization,omitempty"` 403 | 404 | // Encoding, encryption, or digest algorithm. 405 | Alg string `json:"alg"` // default:ES256 406 | 407 | // Specifies format of security authentication information. 408 | Format string `json:"format"` // default: jwt 409 | } 410 | 411 | /* 412 | Certificate-based asymmetric key security configuration conformant with [X509V3] identified by the Vocabulary Term cert (i.e., "scheme": "cert"). 413 | */ 414 | type CertSecurityScheme struct { 415 | // Identifier providing information which can be used for selection or confirmation. 416 | Identity string `json:"identity,omitempty"` 417 | } 418 | 419 | /* 420 | Pre-shared key authentication security configuration identified by the Vocabulary Term psk (i.e., "scheme": "psk"). 421 | */ 422 | type PSKSecurityScheme struct { 423 | // Identifier providing information which can be used for selection or confirmation. 424 | Identity string `json:"identity,omitempty"` 425 | } 426 | 427 | /* 428 | Raw public key asymmetric key security configuration identified by the Vocabulary Term public (i.e., "scheme": "public"). 429 | */ 430 | type PublicSecurityScheme struct { 431 | // Identifier providing information which can be used for selection or confirmation. 432 | Identity string `json:"identity,omitempty"` 433 | } 434 | 435 | /* 436 | Proof-of-possession (PoP) token authentication security configuration identified by the Vocabulary Term pop (i.e., "scheme": "pop"). Here jwt indicates conformance with [RFC7519], jws indicates conformance with [RFC7797], cwt indicates conformance with [RFC8392], and jwe indicates conformance with [RFC7516], with values for alg interpreted consistently with those standards. 437 | Other formats and algorithms for PoP tokens MAY be specified in vocabulary extensions.. 438 | */ 439 | type PoPSecurityScheme struct { 440 | // Specifies the location of security authentication information. 441 | In string `json:"in"` // default: header 442 | 443 | // Name for query, header, or cookie parameters. 444 | Name string `json:"name,omitempty"` 445 | 446 | // Encoding, encryption, or digest algorithm. 447 | Alg string `json:"alg"` // default:ES256 448 | 449 | // Specifies format of security authentication information. 450 | Format string `json:"format"` // default: jwt 451 | 452 | // URI of the authorization server. 453 | Authorization AnyURI `json:"authorization,omitempty"` 454 | } 455 | 456 | /* 457 | OAuth2 authentication security configuration for systems conformant with [RFC6749] and [RFC8252], identified by the Vocabulary Term oauth2 (i.e., "scheme": "oauth2"). 458 | For the implicit flow authorization MUST be included. For the password and client flows token MUST be included. 459 | For the code flow both authorization and token MUST be included. If no scopes are defined in the SecurityScheme then they are considered to be empty. 460 | */ 461 | type OAuth2SecurityScheme struct { 462 | // URI of the authorization server. 463 | Authorization AnyURI `json:"authorization,omitempty"` 464 | 465 | //URI of the token server. 466 | Token AnyURI `json:"token,omitempty"` 467 | 468 | //URI of the refresh server. 469 | Refresh AnyURI `json:"refresh,omitempty"` 470 | 471 | //Set of authorization scope identifiers provided as an array. These are provided in tokens returned by an authorization server and associated with forms in order to identify what resources a client may access and how. The values associated with a form should be chosen from those defined in an OAuth2SecurityScheme active on that form. 472 | Scopes any `json:"scopes,omitempty"` 473 | 474 | //Authorization flow. 475 | Flow string `json:"flow"` 476 | } 477 | -------------------------------------------------------------------------------- /wot/validation.go: -------------------------------------------------------------------------------- 1 | package wot 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | 7 | "github.com/xeipuuv/gojsonschema" 8 | ) 9 | 10 | type jsonSchema = *gojsonschema.Schema 11 | 12 | var loadedJSONSchemas []jsonSchema 13 | 14 | // ReadJSONSchema reads the a JSONSchema from a file 15 | func readJSONSchema(path string) (jsonSchema, error) { 16 | file, err := ioutil.ReadFile(path) 17 | if err != nil { 18 | return nil, fmt.Errorf("error reading file: %s", err) 19 | } 20 | 21 | schema, err := gojsonschema.NewSchema(gojsonschema.NewBytesLoader(file)) 22 | if err != nil { 23 | return nil, fmt.Errorf("error loading schema: %s", err) 24 | } 25 | return schema, nil 26 | } 27 | 28 | // LoadJSONSchemas loads one or more JSON Schemas into memory 29 | func LoadJSONSchemas(paths []string) error { 30 | if len(loadedJSONSchemas) != 0 { 31 | panic("Unexpected re-loading of JSON Schemas.") 32 | } 33 | var schemas []jsonSchema 34 | for _, path := range paths { 35 | schema, err := readJSONSchema(path) 36 | if err != nil { 37 | return err 38 | } 39 | schemas = append(schemas, schema) 40 | } 41 | loadedJSONSchemas = schemas 42 | return nil 43 | } 44 | 45 | // LoadedJSONSchemas checks whether any JSON Schema has been loaded into memory 46 | func LoadedJSONSchemas() bool { 47 | return len(loadedJSONSchemas) > 0 48 | } 49 | 50 | func validateAgainstSchema(td *map[string]interface{}, schema jsonSchema) ([]ValidationError, error) { 51 | result, err := schema.Validate(gojsonschema.NewGoLoader(td)) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | if !result.Valid() { 57 | var issues []ValidationError 58 | for _, re := range result.Errors() { 59 | issues = append(issues, ValidationError{Field: re.Field(), Descr: re.Description()}) 60 | } 61 | return issues, nil 62 | } 63 | 64 | return nil, nil 65 | } 66 | 67 | func validateAgainstSchemas(td *map[string]interface{}, schemas ...jsonSchema) ([]ValidationError, error) { 68 | var validationErrors []ValidationError 69 | for _, schema := range schemas { 70 | result, err := validateAgainstSchema(td, schema) 71 | if err != nil { 72 | return nil, err 73 | } 74 | validationErrors = append(validationErrors, result...) 75 | } 76 | 77 | return validationErrors, nil 78 | } 79 | 80 | // ValidateTD performs input validation using one or more pre-loaded JSON Schemas 81 | // If no schema has been pre-loaded, the function returns as if there are no validation errors 82 | func ValidateTD(td *map[string]interface{}) ([]ValidationError, error) { 83 | return validateAgainstSchemas(td, loadedJSONSchemas...) 84 | } 85 | -------------------------------------------------------------------------------- /wot/validation_test.go: -------------------------------------------------------------------------------- 1 | package wot 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | 8 | "github.com/xeipuuv/gojsonschema" 9 | ) 10 | 11 | const ( 12 | envTestSchemaPath = "TEST_SCHEMA_PATH" 13 | defaultSchemaPath = "../wot/wot_td_schema.json" 14 | ) 15 | 16 | func TestLoadSchemas(t *testing.T) { 17 | if !LoadedJSONSchemas() { 18 | path := os.Getenv(envTestSchemaPath) 19 | if path == "" { 20 | path = defaultSchemaPath 21 | } 22 | err := LoadJSONSchemas([]string{path}) 23 | if err != nil { 24 | t.Fatalf("error loading WoT Thing Description schema: %s", err) 25 | } 26 | } 27 | if len(loadedJSONSchemas) == 0 { 28 | t.Fatalf("JSON Schema was not loaded into memory") 29 | } 30 | } 31 | 32 | func TestValidateAgainstSchema(t *testing.T) { 33 | path := os.Getenv(envTestSchemaPath) 34 | if path == "" { 35 | path = defaultSchemaPath 36 | } 37 | 38 | // load the schema 39 | file, err := ioutil.ReadFile(path) 40 | if err != nil { 41 | t.Fatalf("error reading file: %s", err) 42 | } 43 | schema, err := gojsonschema.NewSchema(gojsonschema.NewBytesLoader(file)) 44 | if err != nil { 45 | t.Fatalf("error loading schema: %s", err) 46 | } 47 | 48 | t.Run("non-URI ID", func(t *testing.T) { 49 | var td = map[string]any{ 50 | "@context": "https://www.w3.org/2019/wot/td/v1", 51 | "id": "not-a-uri", 52 | "title": "example thing", 53 | "security": []string{"basic_sc"}, 54 | "securityDefinitions": map[string]any{ 55 | "basic_sc": map[string]string{ 56 | "in": "header", 57 | "scheme": "basic", 58 | }, 59 | }, 60 | } 61 | results, err := validateAgainstSchema(&td, schema) 62 | if err != nil { 63 | t.Fatalf("internal validation error: %s", err) 64 | } 65 | if len(results) == 0 { 66 | t.Fatalf("Didn't return error on non-URI ID: %s", td["id"]) 67 | } 68 | }) 69 | 70 | t.Run("missing mandatory title", func(t *testing.T) { 71 | var td = map[string]any{ 72 | "@context": "https://www.w3.org/2019/wot/td/v1", 73 | "id": "not-a-uri", 74 | //"title": "example thing", 75 | "security": []string{"basic_sc"}, 76 | "securityDefinitions": map[string]any{ 77 | "basic_sc": map[string]string{ 78 | "in": "header", 79 | "scheme": "basic", 80 | }, 81 | }, 82 | } 83 | results, err := validateAgainstSchema(&td, schema) 84 | if err != nil { 85 | t.Fatalf("internal validation error: %s", err) 86 | } 87 | if len(results) == 0 { 88 | t.Fatalf("Didn't return error on missing mandatory title.") 89 | } 90 | }) 91 | 92 | // TODO test discovery validations 93 | //t.Run("non-float TTL", func(t *testing.T) { 94 | // var td = map[string]any{ 95 | // "@context": "https://www.w3.org/2019/wot/td/v1", 96 | // "id": "urn:example:test/thing1", 97 | // "title": "example thing", 98 | // "security": []string{"basic_sc"}, 99 | // "securityDefinitions": map[string]any{ 100 | // "basic_sc": map[string]string{ 101 | // "in": "header", 102 | // "scheme": "basic", 103 | // }, 104 | // }, 105 | // "registration": map[string]any{ 106 | // "ttl": "60", 107 | // }, 108 | // } 109 | // results, err := validateAgainstSchema(&td, schema) 110 | // if err != nil { 111 | // t.Fatalf("internal validation error: %s", err) 112 | // } 113 | // if len(results) == 0 { 114 | // t.Fatalf("Didn't return error on string TTL.") 115 | // } 116 | //}) 117 | } 118 | -------------------------------------------------------------------------------- /wot/wot_td_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "WoT TD Schema - 16 October 2019", 3 | "description": "JSON Schema for validating TD instances against the TD model. TD instances can be with or without terms that have default values", 4 | "$schema ": "http://json-schema.org/draft-07/schema#", 5 | "definitions": { 6 | "anyUri": { 7 | "type": "string", 8 | "format": "iri-reference" 9 | }, 10 | "description": { 11 | "type": "string" 12 | }, 13 | "descriptions": { 14 | "type": "object", 15 | "additionalProperties": { 16 | "type": "string" 17 | } 18 | }, 19 | "title": { 20 | "type": "string" 21 | }, 22 | "titles": { 23 | "type": "object", 24 | "additionalProperties": { 25 | "type": "string" 26 | } 27 | }, 28 | "security": { 29 | "oneOf": [{ 30 | "type": "array", 31 | "items": { 32 | "type": "string" 33 | } 34 | }, 35 | { 36 | "type": "string" 37 | } 38 | ] 39 | }, 40 | "scopes": { 41 | "oneOf": [{ 42 | "type": "array", 43 | "items": { 44 | "type": "string" 45 | } 46 | }, 47 | { 48 | "type": "string" 49 | } 50 | ] 51 | }, 52 | "subProtocol": { 53 | "type": "string", 54 | "enum": [ 55 | "longpoll", 56 | "websub", 57 | "sse" 58 | ] 59 | }, 60 | "thing-context-w3c-uri": { 61 | "type": "string", 62 | "enum": [ 63 | "https://www.w3.org/2019/wot/td/v1" 64 | ] 65 | }, 66 | "thing-context": { 67 | "oneOf": [{ 68 | "type": "array", 69 | "items": [{ 70 | "$ref": "#/definitions/thing-context-w3c-uri" 71 | }], 72 | "additionalItems": { 73 | "anyOf": [{ 74 | "$ref": "#/definitions/anyUri" 75 | }, 76 | { 77 | "type": "object" 78 | } 79 | ] 80 | } 81 | }, 82 | { 83 | "$ref": "#/definitions/thing-context-w3c-uri" 84 | } 85 | ] 86 | }, 87 | "type_declaration": { 88 | "oneOf": [{ 89 | "type": "string" 90 | }, 91 | { 92 | "type": "array", 93 | "items": { 94 | "type": "string" 95 | } 96 | } 97 | ] 98 | }, 99 | "dataSchema": { 100 | "type": "object", 101 | "properties": { 102 | "@type": { 103 | "$ref": "#/definitions/type_declaration" 104 | }, 105 | "description": { 106 | "$ref": "#/definitions/description" 107 | }, 108 | "title": { 109 | "$ref": "#/definitions/title" 110 | }, 111 | "descriptions": { 112 | "$ref": "#/definitions/descriptions" 113 | }, 114 | "titles": { 115 | "$ref": "#/definitions/titles" 116 | }, 117 | "writeOnly": { 118 | "type": "boolean" 119 | }, 120 | "readOnly": { 121 | "type": "boolean" 122 | }, 123 | "oneOf": { 124 | "type": "array", 125 | "items": { 126 | "$ref": "#/definitions/dataSchema" 127 | } 128 | }, 129 | "unit": { 130 | "type": "string" 131 | }, 132 | "enum": { 133 | "type": "array", 134 | "minItems": 1, 135 | "uniqueItems": true 136 | }, 137 | "format": { 138 | "type": "string" 139 | }, 140 | "const": {}, 141 | "type": { 142 | "type": "string", 143 | "enum": [ 144 | "boolean", 145 | "integer", 146 | "number", 147 | "string", 148 | "object", 149 | "array", 150 | "null" 151 | ] 152 | }, 153 | "items": { 154 | "oneOf": [{ 155 | "$ref": "#/definitions/dataSchema" 156 | }, 157 | { 158 | "type": "array", 159 | "items": { 160 | "$ref": "#/definitions/dataSchema" 161 | } 162 | } 163 | ] 164 | }, 165 | "maxItems": { 166 | "type": "integer", 167 | "minimum": 0 168 | }, 169 | "minItems": { 170 | "type": "integer", 171 | "minimum": 0 172 | }, 173 | "minimum": { 174 | "type": "number" 175 | }, 176 | "maximum": { 177 | "type": "number" 178 | }, 179 | "properties": { 180 | "additionalProperties": { 181 | "$ref": "#/definitions/dataSchema" 182 | } 183 | }, 184 | "required": { 185 | "type": "array", 186 | "items": { 187 | "type": "string" 188 | } 189 | } 190 | } 191 | }, 192 | "form_element_property": { 193 | "type": "object", 194 | "properties": { 195 | "op": { 196 | "oneOf": [{ 197 | "type": "string", 198 | "enum": [ 199 | "readproperty", 200 | "writeproperty", 201 | "observeproperty", 202 | "unobserveproperty" 203 | ] 204 | }, 205 | { 206 | "type": "array", 207 | "items": { 208 | "type": "string", 209 | "enum": [ 210 | "readproperty", 211 | "writeproperty", 212 | "observeproperty", 213 | "unobserveproperty" 214 | ] 215 | } 216 | } 217 | ] 218 | }, 219 | "href": { 220 | "$ref": "#/definitions/anyUri" 221 | }, 222 | "contentType": { 223 | "type": "string" 224 | }, 225 | "contentCoding": { 226 | "type": "string" 227 | }, 228 | "subProtocol": { 229 | "$ref": "#/definitions/subProtocol" 230 | }, 231 | "security": { 232 | "$ref": "#/definitions/security" 233 | }, 234 | "scopes": { 235 | "$ref": "#/definitions/scopes" 236 | }, 237 | "response": { 238 | "type": "object", 239 | "properties": { 240 | "contentType": { 241 | "type": "string" 242 | } 243 | } 244 | } 245 | }, 246 | "required": [ 247 | "href" 248 | ], 249 | "additionalProperties": true 250 | }, 251 | "form_element_action": { 252 | "type": "object", 253 | "properties": { 254 | "op": { 255 | "oneOf": [{ 256 | "type": "string", 257 | "enum": [ 258 | "invokeaction" 259 | ] 260 | }, 261 | { 262 | "type": "array", 263 | "items": { 264 | "type": "string", 265 | "enum": [ 266 | "invokeaction" 267 | ] 268 | } 269 | } 270 | ] 271 | }, 272 | "href": { 273 | "$ref": "#/definitions/anyUri" 274 | }, 275 | "contentType": { 276 | "type": "string" 277 | }, 278 | "contentCoding": { 279 | "type": "string" 280 | }, 281 | "subProtocol": { 282 | "$ref": "#/definitions/subProtocol" 283 | }, 284 | "security": { 285 | "$ref": "#/definitions/security" 286 | }, 287 | "scopes": { 288 | "$ref": "#/definitions/scopes" 289 | }, 290 | "response": { 291 | "type": "object", 292 | "properties": { 293 | "contentType": { 294 | "type": "string" 295 | } 296 | } 297 | } 298 | }, 299 | "required": [ 300 | "href" 301 | ], 302 | "additionalProperties": true 303 | }, 304 | "form_element_event": { 305 | "type": "object", 306 | "properties": { 307 | "op": { 308 | "oneOf": [{ 309 | "type": "string", 310 | "enum": [ 311 | "subscribeevent", 312 | "unsubscribeevent" 313 | ] 314 | }, 315 | { 316 | "type": "array", 317 | "items": { 318 | "type": "string", 319 | "enum": [ 320 | "subscribeevent", 321 | "unsubscribeevent" 322 | ] 323 | } 324 | } 325 | ] 326 | }, 327 | "href": { 328 | "$ref": "#/definitions/anyUri" 329 | }, 330 | "contentType": { 331 | "type": "string" 332 | }, 333 | "contentCoding": { 334 | "type": "string" 335 | }, 336 | "subProtocol": { 337 | "$ref": "#/definitions/subProtocol" 338 | }, 339 | "security": { 340 | "$ref": "#/definitions/security" 341 | }, 342 | "scopes": { 343 | "$ref": "#/definitions/scopes" 344 | }, 345 | "response": { 346 | "type": "object", 347 | "properties": { 348 | "contentType": { 349 | "type": "string" 350 | } 351 | } 352 | } 353 | }, 354 | "required": [ 355 | "href" 356 | ], 357 | "additionalProperties": true 358 | }, 359 | "form_element_root": { 360 | "type": "object", 361 | "properties": { 362 | "op": { 363 | "oneOf": [{ 364 | "type": "string", 365 | "enum": [ 366 | "readallproperties", 367 | "writeallproperties", 368 | "readmultipleproperties", 369 | "writemultipleproperties" 370 | ] 371 | }, 372 | { 373 | "type": "array", 374 | "items": { 375 | "type": "string", 376 | "enum": [ 377 | "readallproperties", 378 | "writeallproperties", 379 | "readmultipleproperties", 380 | "writemultipleproperties" 381 | ] 382 | } 383 | } 384 | ] 385 | }, 386 | "href": { 387 | "$ref": "#/definitions/anyUri" 388 | }, 389 | "contentType": { 390 | "type": "string" 391 | }, 392 | "contentCoding": { 393 | "type": "string" 394 | }, 395 | "subProtocol": { 396 | "$ref": "#/definitions/subProtocol" 397 | }, 398 | "security": { 399 | "$ref": "#/definitions/security" 400 | }, 401 | "scopes": { 402 | "$ref": "#/definitions/scopes" 403 | }, 404 | "response": { 405 | "type": "object", 406 | "properties": { 407 | "contentType": { 408 | "type": "string" 409 | } 410 | } 411 | } 412 | }, 413 | "required": [ 414 | "href" 415 | ], 416 | "additionalProperties": true 417 | }, 418 | "property_element": { 419 | "type": "object", 420 | "properties": { 421 | "@type": { 422 | "$ref": "#/definitions/type_declaration" 423 | }, 424 | "description": { 425 | "$ref": "#/definitions/description" 426 | }, 427 | "descriptions": { 428 | "$ref": "#/definitions/descriptions" 429 | }, 430 | "title": { 431 | "$ref": "#/definitions/title" 432 | }, 433 | "titles": { 434 | "$ref": "#/definitions/titles" 435 | }, 436 | "forms": { 437 | "type": "array", 438 | "minItems": 1, 439 | "items": { 440 | "$ref": "#/definitions/form_element_property" 441 | } 442 | }, 443 | "uriVariables": { 444 | "type": "object", 445 | "additionalProperties": { 446 | "$ref": "#/definitions/dataSchema" 447 | } 448 | }, 449 | "observable": { 450 | "type": "boolean" 451 | }, 452 | "writeOnly": { 453 | "type": "boolean" 454 | }, 455 | "readOnly": { 456 | "type": "boolean" 457 | }, 458 | "oneOf": { 459 | "type": "array", 460 | "items": { 461 | "$ref": "#/definitions/dataSchema" 462 | } 463 | }, 464 | "unit": { 465 | "type": "string" 466 | }, 467 | "enum": { 468 | "type": "array", 469 | "minItems": 1, 470 | "uniqueItems": true 471 | }, 472 | "format": { 473 | "type": "string" 474 | }, 475 | "const": {}, 476 | "type": { 477 | "type": "string", 478 | "enum": [ 479 | "boolean", 480 | "integer", 481 | "number", 482 | "string", 483 | "object", 484 | "array", 485 | "null" 486 | ] 487 | }, 488 | "items": { 489 | "oneOf": [{ 490 | "$ref": "#/definitions/dataSchema" 491 | }, 492 | { 493 | "type": "array", 494 | "items": { 495 | "$ref": "#/definitions/dataSchema" 496 | } 497 | } 498 | ] 499 | }, 500 | "maxItems": { 501 | "type": "integer", 502 | "minimum": 0 503 | }, 504 | "minItems": { 505 | "type": "integer", 506 | "minimum": 0 507 | }, 508 | "minimum": { 509 | "type": "number" 510 | }, 511 | "maximum": { 512 | "type": "number" 513 | }, 514 | "properties": { 515 | "additionalProperties": { 516 | "$ref": "#/definitions/dataSchema" 517 | } 518 | }, 519 | "required": { 520 | "type": "array", 521 | "items": { 522 | "type": "string" 523 | } 524 | } 525 | }, 526 | "required": [ 527 | "forms" 528 | ], 529 | "additionalProperties": true 530 | }, 531 | "action_element": { 532 | "type": "object", 533 | "properties": { 534 | "@type": { 535 | "$ref": "#/definitions/type_declaration" 536 | }, 537 | "description": { 538 | "$ref": "#/definitions/description" 539 | }, 540 | "descriptions": { 541 | "$ref": "#/definitions/descriptions" 542 | }, 543 | "title": { 544 | "$ref": "#/definitions/title" 545 | }, 546 | "titles": { 547 | "$ref": "#/definitions/titles" 548 | }, 549 | "forms": { 550 | "type": "array", 551 | "minItems": 1, 552 | "items": { 553 | "$ref": "#/definitions/form_element_action" 554 | } 555 | }, 556 | "uriVariables": { 557 | "type": "object", 558 | "additionalProperties": { 559 | "$ref": "#/definitions/dataSchema" 560 | } 561 | }, 562 | "input": { 563 | "$ref": "#/definitions/dataSchema" 564 | }, 565 | "output": { 566 | "$ref": "#/definitions/dataSchema" 567 | }, 568 | "safe": { 569 | "type": "boolean" 570 | }, 571 | "idempotent": { 572 | "type": "boolean" 573 | } 574 | }, 575 | "required": [ 576 | "forms" 577 | ], 578 | "additionalProperties": true 579 | }, 580 | "event_element": { 581 | "type": "object", 582 | "properties": { 583 | "@type": { 584 | "$ref": "#/definitions/type_declaration" 585 | }, 586 | "description": { 587 | "$ref": "#/definitions/description" 588 | }, 589 | "descriptions": { 590 | "$ref": "#/definitions/descriptions" 591 | }, 592 | "title": { 593 | "$ref": "#/definitions/title" 594 | }, 595 | "titles": { 596 | "$ref": "#/definitions/titles" 597 | }, 598 | "forms": { 599 | "type": "array", 600 | "minItems": 1, 601 | "items": { 602 | "$ref": "#/definitions/form_element_event" 603 | } 604 | }, 605 | "uriVariables": { 606 | "type": "object", 607 | "additionalProperties": { 608 | "$ref": "#/definitions/dataSchema" 609 | } 610 | }, 611 | "subscription": { 612 | "$ref": "#/definitions/dataSchema" 613 | }, 614 | "data": { 615 | "$ref": "#/definitions/dataSchema" 616 | }, 617 | "cancellation": { 618 | "$ref": "#/definitions/dataSchema" 619 | } 620 | }, 621 | "required": [ 622 | "forms" 623 | ], 624 | "additionalProperties": true 625 | }, 626 | "link_element": { 627 | "type": "object", 628 | "properties": { 629 | "href": { 630 | "$ref": "#/definitions/anyUri" 631 | }, 632 | "type": { 633 | "type": "string" 634 | }, 635 | "rel": { 636 | "type": "string" 637 | }, 638 | "anchor": { 639 | "$ref": "#/definitions/anyUri" 640 | } 641 | }, 642 | "required": [ 643 | "href" 644 | ], 645 | "additionalProperties": true 646 | }, 647 | "securityScheme": { 648 | "oneOf": [{ 649 | "type": "object", 650 | "properties": { 651 | "@type": { 652 | "$ref": "#/definitions/type_declaration" 653 | }, 654 | "description": { 655 | "$ref": "#/definitions/description" 656 | }, 657 | "descriptions": { 658 | "$ref": "#/definitions/descriptions" 659 | }, 660 | "proxy": { 661 | "$ref": "#/definitions/anyUri" 662 | }, 663 | "scheme": { 664 | "type": "string", 665 | "enum": [ 666 | "nosec" 667 | ] 668 | } 669 | }, 670 | "required": [ 671 | "scheme" 672 | ] 673 | }, 674 | { 675 | "type": "object", 676 | "properties": { 677 | "@type": { 678 | "$ref": "#/definitions/type_declaration" 679 | }, 680 | "description": { 681 | "$ref": "#/definitions/description" 682 | }, 683 | "descriptions": { 684 | "$ref": "#/definitions/descriptions" 685 | }, 686 | "proxy": { 687 | "$ref": "#/definitions/anyUri" 688 | }, 689 | "scheme": { 690 | "type": "string", 691 | "enum": [ 692 | "basic" 693 | ] 694 | }, 695 | "in": { 696 | "type": "string", 697 | "enum": [ 698 | "header", 699 | "query", 700 | "body", 701 | "cookie" 702 | ] 703 | }, 704 | "name": { 705 | "type": "string" 706 | } 707 | }, 708 | "required": [ 709 | "scheme" 710 | ] 711 | }, 712 | { 713 | "type": "object", 714 | "properties": { 715 | "@type": { 716 | "$ref": "#/definitions/type_declaration" 717 | }, 718 | "description": { 719 | "$ref": "#/definitions/description" 720 | }, 721 | "descriptions": { 722 | "$ref": "#/definitions/descriptions" 723 | }, 724 | "proxy": { 725 | "$ref": "#/definitions/anyUri" 726 | }, 727 | "scheme": { 728 | "type": "string", 729 | "enum": [ 730 | "digest" 731 | ] 732 | }, 733 | "qop": { 734 | "type": "string", 735 | "enum": [ 736 | "auth", 737 | "auth-int" 738 | ] 739 | }, 740 | "in": { 741 | "type": "string", 742 | "enum": [ 743 | "header", 744 | "query", 745 | "body", 746 | "cookie" 747 | ] 748 | }, 749 | "name": { 750 | "type": "string" 751 | } 752 | }, 753 | "required": [ 754 | "scheme" 755 | ] 756 | }, 757 | { 758 | "type": "object", 759 | "properties": { 760 | "@type": { 761 | "$ref": "#/definitions/type_declaration" 762 | }, 763 | "description": { 764 | "$ref": "#/definitions/description" 765 | }, 766 | "descriptions": { 767 | "$ref": "#/definitions/descriptions" 768 | }, 769 | "proxy": { 770 | "$ref": "#/definitions/anyUri" 771 | }, 772 | "scheme": { 773 | "type": "string", 774 | "enum": [ 775 | "apikey" 776 | ] 777 | }, 778 | "in": { 779 | "type": "string", 780 | "enum": [ 781 | "header", 782 | "query", 783 | "body", 784 | "cookie" 785 | ] 786 | }, 787 | "name": { 788 | "type": "string" 789 | } 790 | }, 791 | "required": [ 792 | "scheme" 793 | ] 794 | }, 795 | { 796 | "type": "object", 797 | "properties": { 798 | "@type": { 799 | "$ref": "#/definitions/type_declaration" 800 | }, 801 | "description": { 802 | "$ref": "#/definitions/description" 803 | }, 804 | "descriptions": { 805 | "$ref": "#/definitions/descriptions" 806 | }, 807 | "proxy": { 808 | "$ref": "#/definitions/anyUri" 809 | }, 810 | "scheme": { 811 | "type": "string", 812 | "enum": [ 813 | "bearer" 814 | ] 815 | }, 816 | "authorization": { 817 | "$ref": "#/definitions/anyUri" 818 | }, 819 | "alg": { 820 | "type": "string" 821 | }, 822 | "format": { 823 | "type": "string" 824 | }, 825 | "in": { 826 | "type": "string", 827 | "enum": [ 828 | "header", 829 | "query", 830 | "body", 831 | "cookie" 832 | ] 833 | }, 834 | "name": { 835 | "type": "string" 836 | } 837 | }, 838 | "required": [ 839 | "scheme" 840 | ] 841 | }, 842 | { 843 | "type": "object", 844 | "properties": { 845 | "@type": { 846 | "$ref": "#/definitions/type_declaration" 847 | }, 848 | "description": { 849 | "$ref": "#/definitions/description" 850 | }, 851 | "descriptions": { 852 | "$ref": "#/definitions/descriptions" 853 | }, 854 | "proxy": { 855 | "$ref": "#/definitions/anyUri" 856 | }, 857 | "scheme": { 858 | "type": "string", 859 | "enum": [ 860 | "psk" 861 | ] 862 | }, 863 | "identity": { 864 | "type": "string" 865 | } 866 | }, 867 | "required": [ 868 | "scheme" 869 | ] 870 | }, 871 | { 872 | "type": "object", 873 | "properties": { 874 | "@type": { 875 | "$ref": "#/definitions/type_declaration" 876 | }, 877 | "description": { 878 | "$ref": "#/definitions/description" 879 | }, 880 | "descriptions": { 881 | "$ref": "#/definitions/descriptions" 882 | }, 883 | "proxy": { 884 | "$ref": "#/definitions/anyUri" 885 | }, 886 | "scheme": { 887 | "type": "string", 888 | "enum": [ 889 | "oauth2" 890 | ] 891 | }, 892 | "authorization": { 893 | "$ref": "#/definitions/anyUri" 894 | }, 895 | "token": { 896 | "$ref": "#/definitions/anyUri" 897 | }, 898 | "refresh": { 899 | "$ref": "#/definitions/anyUri" 900 | }, 901 | "scopes": { 902 | "oneOf": [{ 903 | "type": "array", 904 | "items": { 905 | "type": "string" 906 | } 907 | }, 908 | { 909 | "type": "string" 910 | } 911 | ] 912 | }, 913 | "flow": { 914 | "type": "string", 915 | "enum": [ 916 | "code" 917 | ] 918 | } 919 | }, 920 | "required": [ 921 | "scheme" 922 | ] 923 | } 924 | ] 925 | } 926 | }, 927 | "type": "object", 928 | "properties": { 929 | "id": { 930 | "type": "string", 931 | "format": "uri" 932 | }, 933 | "title": { 934 | "$ref": "#/definitions/title" 935 | }, 936 | "titles": { 937 | "$ref": "#/definitions/titles" 938 | }, 939 | "properties": { 940 | "type": "object", 941 | "additionalProperties": { 942 | "$ref": "#/definitions/property_element" 943 | } 944 | }, 945 | "actions": { 946 | "type": "object", 947 | "additionalProperties": { 948 | "$ref": "#/definitions/action_element" 949 | } 950 | }, 951 | "events": { 952 | "type": "object", 953 | "additionalProperties": { 954 | "$ref": "#/definitions/event_element" 955 | } 956 | }, 957 | "description": { 958 | "$ref": "#/definitions/description" 959 | }, 960 | "descriptions": { 961 | "$ref": "#/definitions/descriptions" 962 | }, 963 | "version": { 964 | "type": "object", 965 | "properties": { 966 | "instance": { 967 | "type": "string" 968 | } 969 | }, 970 | "required": [ 971 | "instance" 972 | ] 973 | }, 974 | "links": { 975 | "type": "array", 976 | "items": { 977 | "$ref": "#/definitions/link_element" 978 | } 979 | }, 980 | "forms": { 981 | "type": "array", 982 | "minItems": 1, 983 | "items": { 984 | "$ref": "#/definitions/form_element_root" 985 | } 986 | }, 987 | "base": { 988 | "$ref": "#/definitions/anyUri" 989 | }, 990 | "securityDefinitions": { 991 | "type": "object", 992 | "minProperties": 1, 993 | "additionalProperties": { 994 | "$ref": "#/definitions/securityScheme" 995 | } 996 | }, 997 | "support": { 998 | "$ref": "#/definitions/anyUri" 999 | }, 1000 | "created": { 1001 | "type": "string", 1002 | "format": "date-time" 1003 | }, 1004 | "modified": { 1005 | "type": "string", 1006 | "format": "date-time" 1007 | }, 1008 | "security": { 1009 | "oneOf": [{ 1010 | "type": "string" 1011 | }, 1012 | { 1013 | "type": "array", 1014 | "minItems": 1, 1015 | "items": { 1016 | "type": "string" 1017 | } 1018 | } 1019 | ] 1020 | }, 1021 | "@type": { 1022 | "$ref": "#/definitions/type_declaration" 1023 | }, 1024 | "@context": { 1025 | "$ref": "#/definitions/thing-context" 1026 | } 1027 | }, 1028 | "required": [ 1029 | "title", 1030 | "security", 1031 | "securityDefinitions", 1032 | "@context" 1033 | ], 1034 | "additionalProperties": true 1035 | } 1036 | --------------------------------------------------------------------------------