├── .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 |
Version: {{.Version}}
84 | 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 | --------------------------------------------------------------------------------