├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── tip-test.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── alert_group_settings.go ├── alert_group_settings_test.go ├── alerts.go ├── alerts_test.go ├── aws_integrations.go ├── aws_integrations_test.go ├── channels.go ├── channels_test.go ├── checks.go ├── checks_test.go ├── dashboards.go ├── dashboards_test.go ├── downtimes.go ├── downtimes_test.go ├── error.go ├── go.mod ├── go.sum ├── graph_annotation.go ├── graph_annotation_test.go ├── graph_defs.go ├── graph_defs_test.go ├── host_metadata.go ├── host_metadata_test.go ├── hosts.go ├── hosts_test.go ├── invitations.go ├── invitations_test.go ├── mackerel.go ├── mackerel_test.go ├── metrics.go ├── metrics_test.go ├── monitors.go ├── monitors_test.go ├── notification_groups.go ├── notification_groups_test.go ├── org.go ├── org_test.go ├── role_metadata.go ├── role_metadata_test.go ├── roles.go ├── roles_test.go ├── service_metadata.go ├── service_metadata_test.go ├── services.go ├── services_test.go ├── users.go └── users_test.go /.gitattributes: -------------------------------------------------------------------------------- 1 | *.go text eol=lf 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | schedule: 6 | interval: daily 7 | time: "01:00" 8 | timezone: Asia/Tokyo 9 | open-pull-requests-limit: 10 10 | - package-ecosystem: github-actions 11 | directory: / 12 | schedule: 13 | interval: weekly 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | jobs: 9 | lint: 10 | uses: mackerelio/workflows/.github/workflows/go-lint.yml@v1.4.0 11 | test: 12 | uses: mackerelio/workflows/.github/workflows/go-test.yml@v1.4.0 13 | -------------------------------------------------------------------------------- /.github/workflows/tip-test.yml: -------------------------------------------------------------------------------- 1 | name: Test with gotip 2 | 3 | on: 4 | schedule: 5 | - cron: "0 1 * * 1-5" 6 | 7 | jobs: 8 | test: 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | os-version: ["ubuntu-22.04", "macos-14", "windows-2025"] 13 | runs-on: ${{ matrix.os-version }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-go@v5 17 | with: 18 | go-version: stable 19 | cache: false 20 | - name: Install gotip 21 | shell: bash 22 | run: | 23 | go install golang.org/dl/gotip@latest 24 | GOROOT_BOOTSTRAP="$(go env GOROOT)" gotip download 25 | - name: Test 26 | shell: bash 27 | run: gotip test -race ./... 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitattributes 3 | !.github 4 | !.gitignore 5 | !.dependabot 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | go test -v ./... 4 | 5 | .PHONY: lint 6 | lint: 7 | golangci-lint run 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | mackerel-client-go 2 | ================== 3 | 4 | [![Build Status](https://github.com/mackerelio/mackerel-client-go/workflows/Build/badge.svg?branch=master)][actions] 5 | [![pkg.go.dev](https://pkg.go.dev/badge/github.com/mackerelio/mackerel-client-go)][pkg.go.dev] 6 | 7 | [actions]: https://github.com/mackerelio/mackerel-client-go/actions?workflow=Build 8 | [pkg.go.dev]: https://pkg.go.dev/github.com/mackerelio/mackerel-client-go 9 | 10 | mackerel-client-go is a Go client library for [mackerel.io API](https://mackerel.io/api-docs/). 11 | 12 | # Usage 13 | 14 | ```go 15 | import "github.com/mackerelio/mackerel-client-go" 16 | ``` 17 | 18 | ```go 19 | client := mackerel.NewClient("") 20 | 21 | hosts, err := client.FindHosts(&mackerel.FindHostsParam{ 22 | Service: "My-Service", 23 | Roles: []string{"proxy"}, 24 | Statuses: []string{mackerel.HostStatusWorking}, 25 | }) 26 | 27 | err := client.PostServiceMetricValues("My-Service", []*mackerel.MetricValue{ 28 | &mackerel.MetricValue{ 29 | Name: "proxy.access_log.latency", 30 | Time: 123456789, 31 | Value: 500, 32 | }, 33 | }) 34 | ``` 35 | 36 | # CAUTION 37 | 38 | Now, mackerel-client-go is an ALPHA version. In the future release, it may change it's interface. 39 | 40 | # CONTRIBUTION 41 | 42 | 1. Fork ([https://github.com/mackerelio/mackerel-client-go/fork](https://github.com/mackerelio/mackerel-client-go/fork)) 43 | 1. Create a feature branch 44 | 1. Commit your changes 45 | 1. Rebase your local changes against the master branch 46 | 1. Run test suite with the `go test ./...` command and confirm that it passes 47 | 1. Run `gofmt -s` 48 | 1. Create new Pull Request 49 | 50 | License 51 | ---------- 52 | 53 | Copyright 2014 Hatena Co., Ltd. 54 | 55 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at 56 | 57 | http://www.apache.org/licenses/LICENSE-2.0 58 | 59 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 60 | -------------------------------------------------------------------------------- /alert_group_settings.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import "fmt" 4 | 5 | // AlertGroupSetting represents a Mackerel alert group setting. 6 | // ref. https://mackerel.io/api-docs/entry/alert-group-settings 7 | type AlertGroupSetting struct { 8 | ID string `json:"id,omitempty"` 9 | Name string `json:"name"` 10 | Memo string `json:"memo,omitempty"` 11 | ServiceScopes []string `json:"serviceScopes,omitempty"` 12 | RoleScopes []string `json:"roleScopes,omitempty"` 13 | MonitorScopes []string `json:"monitorScopes,omitempty"` 14 | NotificationInterval uint64 `json:"notificationInterval,omitempty"` 15 | } 16 | 17 | // FindAlertGroupSettings finds alert group settings. 18 | func (c *Client) FindAlertGroupSettings() ([]*AlertGroupSetting, error) { 19 | data, err := requestGet[struct { 20 | AlertGroupSettings []*AlertGroupSetting `json:"alertGroupSettings"` 21 | }](c, "/api/v0/alert-group-settings") 22 | if err != nil { 23 | return nil, err 24 | } 25 | return data.AlertGroupSettings, nil 26 | } 27 | 28 | // CreateAlertGroupSetting creates an alert group setting. 29 | func (c *Client) CreateAlertGroupSetting(param *AlertGroupSetting) (*AlertGroupSetting, error) { 30 | return requestPost[AlertGroupSetting](c, "/api/v0/alert-group-settings", param) 31 | } 32 | 33 | // GetAlertGroupSetting gets an alert group setting. 34 | func (c *Client) GetAlertGroupSetting(id string) (*AlertGroupSetting, error) { 35 | path := fmt.Sprintf("/api/v0/alert-group-settings/%s", id) 36 | return requestGet[AlertGroupSetting](c, path) 37 | } 38 | 39 | // UpdateAlertGroupSetting updates an alert group setting. 40 | func (c *Client) UpdateAlertGroupSetting(id string, param *AlertGroupSetting) (*AlertGroupSetting, error) { 41 | path := fmt.Sprintf("/api/v0/alert-group-settings/%s", id) 42 | return requestPut[AlertGroupSetting](c, path, param) 43 | } 44 | 45 | // DeleteAlertGroupSetting deletes an alert group setting. 46 | func (c *Client) DeleteAlertGroupSetting(id string) (*AlertGroupSetting, error) { 47 | path := fmt.Sprintf("/api/v0/alert-group-settings/%s", id) 48 | return requestDelete[AlertGroupSetting](c, path) 49 | } 50 | -------------------------------------------------------------------------------- /alert_group_settings_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/kylelemons/godebug/pretty" 12 | ) 13 | 14 | func TestFindAlertGroupSettings(t *testing.T) { 15 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 16 | if req.URL.Path != "/api/v0/alert-group-settings" { 17 | t.Error("request URL should be /api/v0/alert-group-settings but: ", req.URL.Path) 18 | } 19 | 20 | if req.Method != "GET" { 21 | t.Error("request method should be GET but: ", req.Method) 22 | } 23 | 24 | respJSON, _ := json.Marshal(map[string][]map[string]interface{}{ 25 | "alertGroupSettings": { 26 | { 27 | "id": "xxxxxxxxxxx", 28 | "name": "alert group setting #1", 29 | }, 30 | { 31 | "id": "yyyyyyyyyyy", 32 | "name": "alert group setting #2", 33 | "memo": "lorem ipsum...", 34 | "serviceScopes": []string{"my-service"}, 35 | "roleScopes": []string{"my-service: db"}, 36 | "monitorScopes": []string{"connectivity"}, 37 | "notificationInterval": 60, 38 | }, 39 | }, 40 | }) 41 | 42 | res.Header()["Content-Type"] = []string{"application/json"} 43 | fmt.Fprint(res, string(respJSON)) 44 | })) 45 | defer ts.Close() 46 | 47 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 48 | 49 | got, err := client.FindAlertGroupSettings() 50 | if err != nil { 51 | t.Error("err should be nil but: ", err) 52 | } 53 | 54 | want := []*AlertGroupSetting{ 55 | { 56 | ID: "xxxxxxxxxxx", 57 | Name: "alert group setting #1", 58 | }, 59 | { 60 | ID: "yyyyyyyyyyy", 61 | Name: "alert group setting #2", 62 | Memo: "lorem ipsum...", 63 | ServiceScopes: []string{"my-service"}, 64 | RoleScopes: []string{"my-service: db"}, 65 | MonitorScopes: []string{"connectivity"}, 66 | NotificationInterval: 60, 67 | }, 68 | } 69 | 70 | if diff := pretty.Compare(got, want); diff != "" { 71 | t.Errorf("fail to get correct data: diff: (-got +want)\n%s", diff) 72 | } 73 | } 74 | 75 | func TestCreateAlertGroupSetting(t *testing.T) { 76 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 77 | if req.URL.Path != "/api/v0/alert-group-settings" { 78 | t.Error("request URL should be /api/v0/alert-group-settings but: ", req.URL.Path) 79 | } 80 | 81 | if req.Method != "POST" { 82 | t.Error("request method should be POST but: ", req.Method) 83 | } 84 | 85 | body, _ := io.ReadAll(req.Body) 86 | var alertGroupSetting AlertGroupSetting 87 | if err := json.Unmarshal(body, &alertGroupSetting); err != nil { 88 | t.Fatal("request body should be decoded as json ", string(body)) 89 | } 90 | 91 | respJSON, _ := json.Marshal(map[string]interface{}{ 92 | "id": "xxxxxxxxxxx", 93 | "name": alertGroupSetting.Name, 94 | "memo": alertGroupSetting.Memo, 95 | "serviceScopes": alertGroupSetting.ServiceScopes, 96 | "roleScopes": alertGroupSetting.RoleScopes, 97 | "monitorScopes": alertGroupSetting.MonitorScopes, 98 | "notificationInterval": alertGroupSetting.NotificationInterval, 99 | }) 100 | 101 | res.Header()["Content-Type"] = []string{"application/json"} 102 | fmt.Fprint(res, string(respJSON)) 103 | })) 104 | defer ts.Close() 105 | 106 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 107 | 108 | param := &AlertGroupSetting{ 109 | Name: "alert group setting", 110 | Memo: "lorem ipsum...", 111 | } 112 | got, err := client.CreateAlertGroupSetting(param) 113 | if err != nil { 114 | t.Error("err should be nil but: ", err) 115 | } 116 | 117 | want := &AlertGroupSetting{ 118 | ID: "xxxxxxxxxxx", 119 | Name: param.Name, 120 | Memo: param.Memo, 121 | } 122 | 123 | if diff := pretty.Compare(got, want); diff != "" { 124 | t.Errorf("fail to get correct data: diff: (-got +want)\n%s", diff) 125 | } 126 | } 127 | 128 | func TestGetAlertGroupSetting(t *testing.T) { 129 | id := "xxxxxxxxxxx" 130 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 131 | if req.URL.Path != fmt.Sprintf("/api/v0/alert-group-settings/%s", id) { 132 | t.Error("request URL should be /api/v0/alert-group-settings/ but: ", req.URL.Path) 133 | } 134 | 135 | if req.Method != "GET" { 136 | t.Error("request method should be GET but: ", req.Method) 137 | } 138 | 139 | respJSON, _ := json.Marshal(map[string]interface{}{ 140 | "id": "xxxxxxxxxxx", 141 | "name": "alert group setting", 142 | "memo": "lorem ipsum...", 143 | "serviceScopes": []string{"my-service"}, 144 | "roleScopes": []string{"my-service: db"}, 145 | "monitorScopes": []string{"connectivity"}, 146 | "notificationInterval": 60, 147 | }) 148 | 149 | res.Header()["Content-Type"] = []string{"application/json"} 150 | fmt.Fprint(res, string(respJSON)) 151 | })) 152 | defer ts.Close() 153 | 154 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 155 | 156 | got, err := client.GetAlertGroupSetting(id) 157 | if err != nil { 158 | t.Error("err should be nil but: ", err) 159 | } 160 | 161 | want := &AlertGroupSetting{ 162 | ID: id, 163 | Name: "alert group setting", 164 | Memo: "lorem ipsum...", 165 | ServiceScopes: []string{"my-service"}, 166 | RoleScopes: []string{"my-service: db"}, 167 | MonitorScopes: []string{"connectivity"}, 168 | NotificationInterval: 60, 169 | } 170 | 171 | if diff := pretty.Compare(got, want); diff != "" { 172 | t.Errorf("fail to get correct data: diff (-got +want)\n%s", diff) 173 | } 174 | } 175 | 176 | func TestUpdateAlertGroupSetting(t *testing.T) { 177 | id := "xxxxxxxxxxx" 178 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 179 | if req.URL.Path != fmt.Sprintf("/api/v0/alert-group-settings/%s", id) { 180 | t.Error("request URL should be /api/v0/alert-group-settings/ but: ", req.URL.Path) 181 | } 182 | 183 | if req.Method != "PUT" { 184 | t.Error("request method should be PUT but: ", req.Method) 185 | } 186 | 187 | body, _ := io.ReadAll(req.Body) 188 | var alertGroupSetting AlertGroupSetting 189 | if err := json.Unmarshal(body, &alertGroupSetting); err != nil { 190 | t.Fatal("request body should be decoded as json ", string(body)) 191 | } 192 | 193 | respJSON, _ := json.Marshal(map[string]interface{}{ 194 | "id": id, 195 | "name": alertGroupSetting.Name, 196 | "memo": "lorem ipsum...", 197 | "serviceScopes": []string{"my-service"}, 198 | "roleScopes": []string{"my-service: db"}, 199 | "monitorScopes": []string{"connectivity"}, 200 | "notificationInterval": 60, 201 | }) 202 | 203 | res.Header()["Content-Type"] = []string{"application/json"} 204 | fmt.Fprint(res, string(respJSON)) 205 | })) 206 | defer ts.Close() 207 | 208 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 209 | 210 | param := &AlertGroupSetting{ 211 | Name: "alert group notification updated", 212 | } 213 | got, err := client.UpdateAlertGroupSetting(id, param) 214 | if err != nil { 215 | t.Error("err should be nil but: ", err) 216 | } 217 | 218 | want := &AlertGroupSetting{ 219 | ID: id, 220 | Name: param.Name, 221 | Memo: "lorem ipsum...", 222 | ServiceScopes: []string{"my-service"}, 223 | RoleScopes: []string{"my-service: db"}, 224 | MonitorScopes: []string{"connectivity"}, 225 | NotificationInterval: 60, 226 | } 227 | 228 | if diff := pretty.Compare(got, want); diff != "" { 229 | t.Errorf("fail to get correct data: diff: (-got +want)\n%s", diff) 230 | } 231 | } 232 | 233 | func TestDeleteAlertGroupSetting(t *testing.T) { 234 | id := "xxxxxxxxxxx" 235 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 236 | if req.URL.Path != fmt.Sprintf("/api/v0/alert-group-settings/%s", id) { 237 | t.Error("request URL should be /api/v0/alert-group-settings/ but: ", req.URL.Path) 238 | } 239 | 240 | if req.Method != "DELETE" { 241 | t.Error("request method should be DELETE but: ", req.Method) 242 | } 243 | 244 | respJSON, _ := json.Marshal(map[string]interface{}{ 245 | "id": id, 246 | "name": "alert group setting", 247 | "memo": "lorem ipsum...", 248 | "serviceScopes": []string{"my-service"}, 249 | "roleScopes": []string{"my-service: db"}, 250 | "monitorScopes": []string{"connectivity"}, 251 | "notificationInterval": 60, 252 | }) 253 | 254 | res.Header()["Content-Type"] = []string{"application/json"} 255 | fmt.Fprint(res, string(respJSON)) 256 | })) 257 | defer ts.Close() 258 | 259 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 260 | 261 | got, err := client.DeleteAlertGroupSetting(id) 262 | if err != nil { 263 | t.Error("err should be nil but: ", err) 264 | } 265 | 266 | want := &AlertGroupSetting{ 267 | ID: id, 268 | Name: "alert group setting", 269 | Memo: "lorem ipsum...", 270 | ServiceScopes: []string{"my-service"}, 271 | RoleScopes: []string{"my-service: db"}, 272 | MonitorScopes: []string{"connectivity"}, 273 | NotificationInterval: 60, 274 | } 275 | 276 | if diff := pretty.Compare(got, want); diff != "" { 277 | t.Errorf("fail to get correct data: diff (-got +want)\n%s", diff) 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /alerts.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | ) 7 | 8 | /* 9 | { 10 | "alerts": [ 11 | { 12 | "id": "2wpLU5fBXbG", 13 | "status": "CRITICAL", 14 | "monitorId": "2cYjfibBkaj", 15 | "type": "connectivity", 16 | "openedAt": 1445399342, 17 | "hostId": "2vJ965ygiXf" 18 | }, 19 | { 20 | "id": "2ust8jNxFH3", 21 | "status": "CRITICAL", 22 | "monitorId": "2cYjfibBkaj", 23 | "type": "connectivity", 24 | "openedAt": 1441939801, 25 | "hostId": "2tFrtykgMib" 26 | } 27 | ] 28 | } 29 | */ 30 | 31 | // Alert information 32 | type Alert struct { 33 | ID string `json:"id,omitempty"` 34 | Status string `json:"status,omitempty"` 35 | MonitorID string `json:"monitorId,omitempty"` 36 | Type string `json:"type,omitempty"` 37 | HostID string `json:"hostId,omitempty"` 38 | Value float64 `json:"value,omitempty"` 39 | Message string `json:"message,omitempty"` 40 | Reason string `json:"reason,omitempty"` 41 | OpenedAt int64 `json:"openedAt,omitempty"` 42 | ClosedAt int64 `json:"closedAt,omitempty"` 43 | Memo string `json:"memo,omitempty"` 44 | } 45 | 46 | // AlertsResp includes alert and next id 47 | type AlertsResp struct { 48 | Alerts []*Alert `json:"alerts"` 49 | NextID string `json:"nextId,omitempty"` 50 | } 51 | 52 | // UpdateAlertParam is for UpdateAlert 53 | type UpdateAlertParam struct { 54 | Memo string `json:"memo,omitempty"` 55 | } 56 | 57 | // UpdateAlertResponse is for UpdateAlert 58 | type UpdateAlertResponse struct { 59 | Memo string `json:"memo,omitempty"` 60 | } 61 | 62 | // AlertLog is the log of alert 63 | // See https://mackerel.io/api-docs/entry/alerts#logs 64 | type AlertLog struct { 65 | ID string `json:"id"` 66 | CreatedAt int64 `json:"createdAt"` 67 | Status string `json:"status"` 68 | Trigger string `json:"trigger"` 69 | MonitorID *string `json:"monitorId"` 70 | TargetValue *float64 `json:"targetValue"` 71 | StatusDetail *struct { 72 | Type string `json:"type"` 73 | Detail struct { 74 | Message string `json:"message"` 75 | Memo string `json:"memo"` 76 | } `json:"detail"` 77 | } `json:"statusDetail,omitempty"` 78 | } 79 | 80 | // FindAlertLogsParam is the parameters for FindAlertLogs 81 | type FindAlertLogsParam struct { 82 | NextId *string 83 | Limit *int 84 | } 85 | 86 | // FindAlertLogsResp is for FindAlertLogs 87 | type FindAlertLogsResp struct { 88 | AlertLogs []*AlertLog `json:"logs"` 89 | NextID string `json:"nextId,omitempty"` 90 | } 91 | 92 | func (c *Client) findAlertsWithParams(params url.Values) (*AlertsResp, error) { 93 | return requestGetWithParams[AlertsResp](c, "/api/v0/alerts", params) 94 | } 95 | 96 | // FindAlerts finds open alerts. 97 | func (c *Client) FindAlerts() (*AlertsResp, error) { 98 | return c.findAlertsWithParams(nil) 99 | } 100 | 101 | // FindAlertsByNextID finds next open alerts by next id. 102 | func (c *Client) FindAlertsByNextID(nextID string) (*AlertsResp, error) { 103 | params := url.Values{} 104 | params.Set("nextId", nextID) 105 | return c.findAlertsWithParams(params) 106 | } 107 | 108 | // FindWithClosedAlerts finds open and close alerts. 109 | func (c *Client) FindWithClosedAlerts() (*AlertsResp, error) { 110 | params := url.Values{} 111 | params.Set("withClosed", "true") 112 | return c.findAlertsWithParams(params) 113 | } 114 | 115 | // FindWithClosedAlertsByNextID finds open and close alerts by next id. 116 | func (c *Client) FindWithClosedAlertsByNextID(nextID string) (*AlertsResp, error) { 117 | params := url.Values{} 118 | params.Set("nextId", nextID) 119 | params.Set("withClosed", "true") 120 | return c.findAlertsWithParams(params) 121 | } 122 | 123 | // GetAlert gets an alert. 124 | func (c *Client) GetAlert(alertID string) (*Alert, error) { 125 | path := fmt.Sprintf("/api/v0/alerts/%s", alertID) 126 | return requestGet[Alert](c, path) 127 | } 128 | 129 | // CloseAlert closes an alert. 130 | func (c *Client) CloseAlert(alertID string, reason string) (*Alert, error) { 131 | path := fmt.Sprintf("/api/v0/alerts/%s/close", alertID) 132 | return requestPost[Alert](c, path, map[string]string{"reason": reason}) 133 | } 134 | 135 | // UpdateAlert updates an alert. 136 | func (c *Client) UpdateAlert(alertID string, param UpdateAlertParam) (*UpdateAlertResponse, error) { 137 | path := fmt.Sprintf("/api/v0/alerts/%s", alertID) 138 | return requestPut[UpdateAlertResponse](c, path, param) 139 | } 140 | 141 | func (p FindAlertLogsParam) toValues() url.Values { 142 | values := url.Values{} 143 | if p.NextId != nil { 144 | values.Set("nextId", *p.NextId) 145 | } 146 | if p.Limit != nil { 147 | values.Set("limit", fmt.Sprintf("%d", *p.Limit)) 148 | } 149 | return values 150 | } 151 | 152 | // FindAlertLogs gets alert logs. 153 | func (c *Client) FindAlertLogs(alertId string, params *FindAlertLogsParam) (*FindAlertLogsResp, error) { 154 | path := fmt.Sprintf("/api/v0/alerts/%s/logs", alertId) 155 | if params == nil { 156 | return requestGet[FindAlertLogsResp](c, path) 157 | } 158 | return requestGetWithParams[FindAlertLogsResp](c, path, params.toValues()) 159 | } 160 | -------------------------------------------------------------------------------- /aws_integrations.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // AWSIntegration AWS integration information 9 | type AWSIntegration struct { 10 | ID string `json:"id"` 11 | Name string `json:"name"` 12 | Memo string `json:"memo"` 13 | Key string `json:"key,omitempty"` 14 | RoleArn string `json:"roleArn,omitempty"` 15 | ExternalID string `json:"externalId,omitempty"` 16 | Region string `json:"region"` 17 | IncludedTags string `json:"includedTags"` 18 | ExcludedTags string `json:"excludedTags"` 19 | Services map[string]*AWSIntegrationService `json:"services"` 20 | } 21 | 22 | // AWSIntegrationService integration settings for each AWS service 23 | type AWSIntegrationService struct { 24 | Enable bool `json:"enable"` 25 | Role *string `json:"role"` 26 | IncludedMetrics []string `json:"includedMetrics"` 27 | ExcludedMetrics []string `json:"excludedMetrics"` 28 | RetireAutomatically bool `json:"retireAutomatically,omitempty"` 29 | } 30 | 31 | type awsIntegrationService = AWSIntegrationService 32 | 33 | type awsIntegrationServiceWithIncludedMetrics struct { 34 | Enable bool `json:"enable"` 35 | Role *string `json:"role"` 36 | IncludedMetrics []string `json:"includedMetrics"` 37 | RetireAutomatically bool `json:"retireAutomatically,omitempty"` 38 | } 39 | 40 | type awsIntegrationServiceWithExcludedMetrics struct { 41 | Enable bool `json:"enable"` 42 | Role *string `json:"role"` 43 | ExcludedMetrics []string `json:"excludedMetrics"` 44 | RetireAutomatically bool `json:"retireAutomatically,omitempty"` 45 | } 46 | 47 | // MarshalJSON implements json.Marshaler 48 | func (a *AWSIntegrationService) MarshalJSON() ([]byte, error) { 49 | // AWS integration create/update APIs only accept either includedMetrics or excludedMetrics 50 | if a.ExcludedMetrics != nil && a.IncludedMetrics == nil { 51 | return json.Marshal(awsIntegrationServiceWithExcludedMetrics{ 52 | Enable: a.Enable, 53 | Role: a.Role, 54 | ExcludedMetrics: a.ExcludedMetrics, 55 | RetireAutomatically: a.RetireAutomatically, 56 | }) 57 | } 58 | if a.ExcludedMetrics == nil && a.IncludedMetrics != nil { 59 | return json.Marshal(awsIntegrationServiceWithIncludedMetrics{ 60 | Enable: a.Enable, 61 | Role: a.Role, 62 | IncludedMetrics: a.IncludedMetrics, 63 | RetireAutomatically: a.RetireAutomatically, 64 | }) 65 | } 66 | return json.Marshal(awsIntegrationService(*a)) 67 | } 68 | 69 | // CreateAWSIntegrationParam parameters for CreateAWSIntegration 70 | type CreateAWSIntegrationParam struct { 71 | Name string `json:"name"` 72 | Memo string `json:"memo"` 73 | Key string `json:"key,omitempty"` 74 | SecretKey string `json:"secretKey,omitempty"` 75 | RoleArn string `json:"roleArn,omitempty"` 76 | ExternalID string `json:"externalId,omitempty"` 77 | Region string `json:"region"` 78 | IncludedTags string `json:"includedTags"` 79 | ExcludedTags string `json:"excludedTags"` 80 | Services map[string]*AWSIntegrationService `json:"services"` 81 | } 82 | 83 | // UpdateAWSIntegrationParam parameters for UpdateAwsIntegration 84 | type UpdateAWSIntegrationParam CreateAWSIntegrationParam 85 | 86 | // ListAWSIntegrationExcludableMetrics List of excludeable metric names for AWS integration 87 | type ListAWSIntegrationExcludableMetrics map[string][]string 88 | 89 | // FindAWSIntegrations finds AWS integration settings. 90 | func (c *Client) FindAWSIntegrations() ([]*AWSIntegration, error) { 91 | data, err := requestGet[struct { 92 | AWSIntegrations []*AWSIntegration `json:"aws_integrations"` 93 | }](c, "/api/v0/aws-integrations") 94 | if err != nil { 95 | return nil, err 96 | } 97 | return data.AWSIntegrations, nil 98 | } 99 | 100 | // CreateAWSIntegration creates an AWS integration setting. 101 | func (c *Client) CreateAWSIntegration(param *CreateAWSIntegrationParam) (*AWSIntegration, error) { 102 | return requestPost[AWSIntegration](c, "/api/v0/aws-integrations", param) 103 | } 104 | 105 | // FindAWSIntegration finds an AWS integration setting. 106 | func (c *Client) FindAWSIntegration(awsIntegrationID string) (*AWSIntegration, error) { 107 | path := fmt.Sprintf("/api/v0/aws-integrations/%s", awsIntegrationID) 108 | return requestGet[AWSIntegration](c, path) 109 | } 110 | 111 | // UpdateAWSIntegration updates an AWS integration setting. 112 | func (c *Client) UpdateAWSIntegration(awsIntegrationID string, param *UpdateAWSIntegrationParam) (*AWSIntegration, error) { 113 | path := fmt.Sprintf("/api/v0/aws-integrations/%s", awsIntegrationID) 114 | return requestPut[AWSIntegration](c, path, param) 115 | } 116 | 117 | // DeleteAWSIntegration deletes an AWS integration setting. 118 | func (c *Client) DeleteAWSIntegration(awsIntegrationID string) (*AWSIntegration, error) { 119 | path := fmt.Sprintf("/api/v0/aws-integrations/%s", awsIntegrationID) 120 | return requestDelete[AWSIntegration](c, path) 121 | } 122 | 123 | // CreateAWSIntegrationExternalID creates an AWS integration External ID. 124 | func (c *Client) CreateAWSIntegrationExternalID() (string, error) { 125 | data, err := requestPost[struct { 126 | ExternalID string `json:"externalId"` 127 | }](c, "/api/v0/aws-integrations-external-id", nil) 128 | if err != nil { 129 | return "", err 130 | } 131 | return data.ExternalID, nil 132 | } 133 | 134 | // ListAWSIntegrationExcludableMetrics lists excludable metrics for AWS integration. 135 | func (c *Client) ListAWSIntegrationExcludableMetrics() (*ListAWSIntegrationExcludableMetrics, error) { 136 | return requestGet[ListAWSIntegrationExcludableMetrics](c, "/api/v0/aws-integrations-excludable-metrics") 137 | } 138 | -------------------------------------------------------------------------------- /channels.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import "fmt" 4 | 5 | // Channel represents a Mackerel notification channel. 6 | // ref. https://mackerel.io/api-docs/entry/channels 7 | type Channel struct { 8 | // ID is excluded when used to call CreateChannel 9 | ID string `json:"id,omitempty"` 10 | 11 | Name string `json:"name"` 12 | Type string `json:"type"` 13 | 14 | SuspendedAt *int64 `json:"suspendedAt,omitempty"` 15 | 16 | // Exists when the type is "email" 17 | Emails *[]string `json:"emails,omitempty"` 18 | UserIDs *[]string `json:"userIds,omitempty"` 19 | 20 | // Exists when the type is "slack" 21 | Mentions Mentions `json:"mentions,omitempty"` 22 | // In order to support both 'not setting this field' and 'setting the field as false', 23 | // this field needed to be *bool not bool. 24 | EnabledGraphImage *bool `json:"enabledGraphImage,omitempty"` 25 | 26 | // Exists when the type is "slack" or "webhook" 27 | URL string `json:"url,omitempty"` 28 | 29 | // Exists when the type is "email", "slack", or "webhook" 30 | Events *[]string `json:"events,omitempty"` 31 | } 32 | 33 | // Mentions represents the structure used for slack channel mentions 34 | type Mentions struct { 35 | OK string `json:"ok,omitempty"` 36 | Warning string `json:"warning,omitempty"` 37 | Critical string `json:"critical,omitempty"` 38 | } 39 | 40 | // FindChannels finds channels. 41 | func (c *Client) FindChannels() ([]*Channel, error) { 42 | data, err := requestGet[struct { 43 | Channels []*Channel `json:"channels"` 44 | }](c, "/api/v0/channels") 45 | if err != nil { 46 | return nil, err 47 | } 48 | return data.Channels, nil 49 | } 50 | 51 | // CreateChannel creates a channel. 52 | func (c *Client) CreateChannel(param *Channel) (*Channel, error) { 53 | return requestPost[Channel](c, "/api/v0/channels", param) 54 | } 55 | 56 | // DeleteChannel deletes a channel. 57 | func (c *Client) DeleteChannel(channelID string) (*Channel, error) { 58 | path := fmt.Sprintf("/api/v0/channels/%s", channelID) 59 | return requestDelete[Channel](c, path) 60 | } 61 | -------------------------------------------------------------------------------- /channels_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | // boolPointer is a helper function to initialize a bool pointer 13 | func boolPointer(b bool) *bool { 14 | return &b 15 | } 16 | 17 | func TestFindChannels(t *testing.T) { 18 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 19 | if req.URL.Path != "/api/v0/channels" { 20 | t.Error("request URL should be /api/v0/channels but: ", req.URL.Path) 21 | } 22 | 23 | if req.Method != "GET" { 24 | t.Error("request method should be GET but: ", req.Method) 25 | } 26 | 27 | respJSON, _ := json.Marshal(map[string][]map[string]interface{}{ 28 | "channels": { 29 | { 30 | "id": "abcdefabc", 31 | "name": "email channel", 32 | "type": "email", 33 | "emails": []string{"test@example.com", "test2@example.com"}, 34 | "userIds": []string{"1234", "2345"}, 35 | "events": []string{"alert"}, 36 | }, 37 | { 38 | "id": "bcdefabcd", 39 | "name": "slack channel", 40 | "type": "slack", 41 | "url": "https://hooks.slack.com/services/TAAAA/BBBB/XXXXX", 42 | "mentions": map[string]interface{}{ 43 | "ok": "ok message", 44 | "warning": "warning message", 45 | }, 46 | "enabledGraphImage": true, 47 | "events": []string{"alert"}, 48 | }, 49 | { 50 | "id": "cdefabcde", 51 | "name": "webhook channel", 52 | "type": "webhook", 53 | "url": "http://example.com/webhook", 54 | "events": []string{"alertGroup"}, 55 | }, 56 | { 57 | "id": "defabcdef", 58 | "name": "line channel", 59 | "type": "line", 60 | "suspendedAt": 12345678, 61 | }, 62 | }, 63 | }) 64 | 65 | res.Header()["Content-Type"] = []string{"application/json"} 66 | fmt.Fprint(res, string(respJSON)) 67 | })) 68 | defer ts.Close() 69 | 70 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 71 | channels, err := client.FindChannels() 72 | 73 | if err != nil { 74 | t.Error("err should be nil but: ", err) 75 | } 76 | if len(channels) != 4 { 77 | t.Error("request has 4 channels but: ", len(channels)) 78 | } 79 | 80 | if channels[0].ID != "abcdefabc" { 81 | t.Error("request has ID but: ", channels[0].ID) 82 | } 83 | if channels[1].ID != "bcdefabcd" { 84 | t.Error("request has ID but: ", channels[1].ID) 85 | } 86 | if channels[2].ID != "cdefabcde" { 87 | t.Error("request has ID but: ", channels[2].ID) 88 | } 89 | if channels[3].ID != "defabcdef" { 90 | t.Error("request has ID but: ", channels[3].ID) 91 | } 92 | if channels[0].Name != "email channel" { 93 | t.Error("request has Name but: ", channels[0].Name) 94 | } 95 | if channels[1].Name != "slack channel" { 96 | t.Error("request has Name but: ", channels[1].Name) 97 | } 98 | if channels[2].Name != "webhook channel" { 99 | t.Error("request has Name but: ", channels[2].Name) 100 | } 101 | if channels[3].Name != "line channel" { 102 | t.Error("request has Name but: ", channels[3].Name) 103 | } 104 | if channels[0].Type != "email" { 105 | t.Error("request has Type but: ", channels[0].Type) 106 | } 107 | if channels[1].Type != "slack" { 108 | t.Error("request has Type but: ", channels[1].Type) 109 | } 110 | if channels[2].Type != "webhook" { 111 | t.Error("request has Type but: ", channels[2].Type) 112 | } 113 | if channels[3].Type != "line" { 114 | t.Error("request has Type but: ", channels[3].Type) 115 | } 116 | if channels[0].SuspendedAt != nil { 117 | t.Error("request has SuspendedAt but: ", channels[0].SuspendedAt) 118 | } 119 | if channels[1].SuspendedAt != nil { 120 | t.Error("request has SuspendedAt but: ", channels[1].SuspendedAt) 121 | } 122 | if channels[2].SuspendedAt != nil { 123 | t.Error("request has SuspendedAt but: ", channels[2].SuspendedAt) 124 | } 125 | if *channels[3].SuspendedAt != 12345678 { 126 | t.Error("request has SuspendedAt but: ", *channels[3].SuspendedAt) 127 | } 128 | if reflect.DeepEqual(*(channels[0].Emails), []string{"test@example.com", "test2@example.com"}) != true { 129 | t.Errorf("Wrong data for emails: %v", *(channels[0].Emails)) 130 | } 131 | if channels[1].Emails != nil { 132 | t.Errorf("Wrong data for emails: %v", *(channels[1].Emails)) 133 | } 134 | if channels[2].Emails != nil { 135 | t.Errorf("Wrong data for emails: %v", *(channels[2].Emails)) 136 | } 137 | if channels[3].Emails != nil { 138 | t.Errorf("Wrong data for emails: %v", *(channels[3].Emails)) 139 | } 140 | if reflect.DeepEqual(*(channels[0].UserIDs), []string{"1234", "2345"}) != true { 141 | t.Errorf("Wrong data for userIds: %v", *(channels[0].UserIDs)) 142 | } 143 | if channels[1].UserIDs != nil { 144 | t.Errorf("Wrong data for userIds: %v", *(channels[1].UserIDs)) 145 | } 146 | if channels[2].UserIDs != nil { 147 | t.Errorf("Wrong data for userIds: %v", *(channels[2].UserIDs)) 148 | } 149 | if channels[3].UserIDs != nil { 150 | t.Errorf("Wrong data for userIds: %v", *(channels[3].UserIDs)) 151 | } 152 | if reflect.DeepEqual(*(channels[0].Events), []string{"alert"}) != true { 153 | t.Errorf("Wrong data for events: %v", *(channels[0].Events)) 154 | } 155 | if reflect.DeepEqual(*(channels[1].Events), []string{"alert"}) != true { 156 | t.Errorf("Wrong data for events: %v", *(channels[1].Events)) 157 | } 158 | if reflect.DeepEqual(*(channels[2].Events), []string{"alertGroup"}) != true { 159 | t.Errorf("Wrong data for events: %v", *(channels[2].Events)) 160 | } 161 | if channels[3].Events != nil { 162 | t.Errorf("Wrong data for events: %v", *(channels[3].Events)) 163 | } 164 | if channels[0].URL != "" { 165 | t.Error("request has no URL but: ", channels[0]) 166 | } 167 | if channels[1].URL != "https://hooks.slack.com/services/TAAAA/BBBB/XXXXX" { 168 | t.Error("request sends json including URL but: ", channels[1]) 169 | } 170 | if channels[2].URL != "http://example.com/webhook" { 171 | t.Error("request sends json including URL but: ", channels[2]) 172 | } 173 | if channels[3].URL != "" { 174 | t.Error("request has no URL but: ", channels[3]) 175 | } 176 | if reflect.DeepEqual(channels[0].Mentions, Mentions{}) != true { 177 | t.Error("request has mentions but: ", channels[0].Mentions) 178 | } 179 | if reflect.DeepEqual(channels[1].Mentions, Mentions{OK: "ok message", Warning: "warning message"}) != true { 180 | t.Error("request has mentions but: ", channels[1].Mentions) 181 | } 182 | if reflect.DeepEqual(channels[2].Mentions, Mentions{}) != true { 183 | t.Error("request has mentions but: ", channels[2].Mentions) 184 | } 185 | if reflect.DeepEqual(channels[3].Mentions, Mentions{}) != true { 186 | t.Error("request has mentions but: ", channels[3].Mentions) 187 | } 188 | if channels[0].EnabledGraphImage != nil { 189 | t.Error("request sends json including enabledGraphImage but: ", *(channels[0].EnabledGraphImage)) 190 | } 191 | if !*(channels[1].EnabledGraphImage) { 192 | t.Error("request sends json including enabledGraphImage but: ", *(channels[1].EnabledGraphImage)) 193 | } 194 | if channels[2].EnabledGraphImage != nil { 195 | t.Error("request sends json including enabledGraphImage but: ", *(channels[2].EnabledGraphImage)) 196 | } 197 | if channels[3].EnabledGraphImage != nil { 198 | t.Error("request sends json including enabledGraphImage but: ", *(channels[3].EnabledGraphImage)) 199 | } 200 | } 201 | 202 | func TestCreateChannel(t *testing.T) { 203 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 204 | if req.URL.Path != "/api/v0/channels" { 205 | t.Error("request URL should be /api/v0/channels but: ", req.URL.Path) 206 | } 207 | 208 | if req.Method != "POST" { 209 | t.Error("request method should be POST but: ", req.Method) 210 | } 211 | 212 | respJSON, _ := json.Marshal(map[string]interface{}{ 213 | "id": "abcdefabc", 214 | "name": "slack channel", 215 | "type": "slack", 216 | "url": "https://hooks.slack.com/services/TAAAA/BBBB/XXXXX", 217 | "mentions": map[string]interface{}{ 218 | "ok": "ok message", 219 | "warning": "warning message", 220 | }, 221 | "enabledGraphImage": true, 222 | "events": []string{"alert"}, 223 | }) 224 | 225 | res.Header()["Content-Type"] = []string{"application/json"} 226 | fmt.Fprint(res, string(respJSON)) 227 | })) 228 | defer ts.Close() 229 | 230 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 231 | 232 | channel, err := client.CreateChannel(&Channel{ 233 | Name: "slack channel", 234 | Type: "slack", 235 | URL: "https://hooks.slack.com/services/TAAAA/BBBB/XXXXX", 236 | Mentions: Mentions{ 237 | OK: "ok message", 238 | Warning: "warning message", 239 | }, 240 | EnabledGraphImage: boolPointer(true), 241 | Events: &[]string{"alert"}, 242 | }) 243 | 244 | if err != nil { 245 | t.Error("err should be nil but: ", err) 246 | } 247 | 248 | if channel.ID != "abcdefabc" { 249 | t.Error("request sends json including ID but: ", channel.ID) 250 | } 251 | if channel.Name != "slack channel" { 252 | t.Error("request sends json including name but: ", channel.Name) 253 | } 254 | if channel.URL != "https://hooks.slack.com/services/TAAAA/BBBB/XXXXX" { 255 | t.Error("request sends json including URL but: ", channel.URL) 256 | } 257 | if reflect.DeepEqual(channel.Mentions, Mentions{OK: "ok message", Warning: "warning message"}) != true { 258 | t.Errorf("Wrong data for mentions: %v", channel.Mentions) 259 | } 260 | if !*channel.EnabledGraphImage { 261 | t.Error("request sends json including enabledGraphImage but: ", *channel.EnabledGraphImage) 262 | } 263 | if reflect.DeepEqual(*(channel.Events), []string{"alert"}) != true { 264 | t.Errorf("Wrong data for events: %v", *(channel.Events)) 265 | } 266 | } 267 | 268 | func TestDeleteChannel(t *testing.T) { 269 | channelID := "abcdefabc" 270 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 271 | if req.URL.Path != fmt.Sprintf("/api/v0/channels/%s", channelID) { 272 | t.Error("request URL should be /api/v0/channels/ but: ", req.URL.Path) 273 | } 274 | 275 | if req.Method != "DELETE" { 276 | t.Error("request method should be DELETE but: ", req.Method) 277 | } 278 | 279 | respJSON, _ := json.Marshal(map[string]interface{}{ 280 | "id": channelID, 281 | "name": "slack channel", 282 | "type": "slack", 283 | "url": "https://hooks.slack.com/services/TAAAA/BBBB/XXXXX", 284 | "mentions": map[string]interface{}{ 285 | "ok": "ok message", 286 | "warning": "warning message", 287 | }, 288 | "enabledGraphImage": true, 289 | "events": []string{"alert"}, 290 | }) 291 | 292 | res.Header()["Content-Type"] = []string{"application/json"} 293 | fmt.Fprint(res, string(respJSON)) 294 | })) 295 | defer ts.Close() 296 | 297 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 298 | 299 | channel, err := client.DeleteChannel(channelID) 300 | 301 | if err != nil { 302 | t.Error("err should be nil but: ", err) 303 | } 304 | 305 | if channel.ID != "abcdefabc" { 306 | t.Error("request sends json including ID but: ", channel.ID) 307 | } 308 | if channel.Name != "slack channel" { 309 | t.Error("request sends json including name but: ", channel.Name) 310 | } 311 | if channel.URL != "https://hooks.slack.com/services/TAAAA/BBBB/XXXXX" { 312 | t.Error("request sends json including URL but: ", channel.URL) 313 | } 314 | if reflect.DeepEqual(channel.Mentions, Mentions{OK: "ok message", Warning: "warning message"}) != true { 315 | t.Errorf("Wrong data for mentions: %v", channel.Mentions) 316 | } 317 | if !*channel.EnabledGraphImage { 318 | t.Error("request sends json including enabledGraphImage but: ", *channel.EnabledGraphImage) 319 | } 320 | if reflect.DeepEqual(*(channel.Events), []string{"alert"}) != true { 321 | t.Errorf("Wrong data for events: %v", *(channel.Events)) 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /checks.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | // CheckStatus represents check monitoring status 4 | type CheckStatus string 5 | 6 | // CheckStatuses 7 | const ( 8 | CheckStatusOK CheckStatus = "OK" 9 | CheckStatusWarning CheckStatus = "WARNING" 10 | CheckStatusCritical CheckStatus = "CRITICAL" 11 | CheckStatusUnknown CheckStatus = "UNKNOWN" 12 | ) 13 | 14 | // CheckReport represents a report of check monitoring 15 | type CheckReport struct { 16 | Source CheckSource `json:"source"` 17 | Name string `json:"name"` 18 | Status CheckStatus `json:"status"` 19 | Message string `json:"message"` 20 | OccurredAt int64 `json:"occurredAt"` 21 | NotificationInterval uint `json:"notificationInterval,omitempty"` 22 | MaxCheckAttempts uint `json:"maxCheckAttempts,omitempty"` 23 | } 24 | 25 | // CheckSource represents interface to which each check source type must confirm to 26 | type CheckSource interface { 27 | CheckType() string 28 | 29 | isCheckSource() 30 | } 31 | 32 | const checkTypeHost = "host" 33 | 34 | // Ensure each check type conforms to the CheckSource interface. 35 | var _ CheckSource = (*checkSourceHost)(nil) 36 | 37 | // Ensure only checkSource types defined in this package can be assigned to the 38 | // CheckSource interface. 39 | func (cs *checkSourceHost) isCheckSource() {} 40 | 41 | type checkSourceHost struct { 42 | Type string `json:"type"` 43 | HostID string `json:"hostId"` 44 | } 45 | 46 | // CheckType is for satisfying CheckSource interface 47 | func (cs *checkSourceHost) CheckType() string { 48 | return checkTypeHost 49 | } 50 | 51 | // NewCheckSourceHost returns new CheckSource which check type is "host" 52 | func NewCheckSourceHost(hostID string) CheckSource { 53 | return &checkSourceHost{ 54 | Type: checkTypeHost, 55 | HostID: hostID, 56 | } 57 | } 58 | 59 | // CheckReports represents check reports for API 60 | type CheckReports struct { 61 | Reports []*CheckReport `json:"reports"` 62 | } 63 | 64 | // PostCheckReports reports check monitoring results. 65 | func (c *Client) PostCheckReports(checkReports *CheckReports) error { 66 | _, err := requestPost[any](c, "/api/v0/monitoring/checks/report", checkReports) 67 | return err 68 | } 69 | -------------------------------------------------------------------------------- /checks_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | ) 11 | 12 | func TestCheckReports_MarshalJSON(t *testing.T) { 13 | crs := &CheckReports{ 14 | Reports: []*CheckReport{ 15 | { 16 | Source: NewCheckSourceHost("hogee"), 17 | Name: "chchch", 18 | Status: CheckStatusCritical, 19 | OccurredAt: 100, 20 | Message: "OKOK", 21 | }, 22 | }, 23 | } 24 | expect := `{"reports":[{"source":{"type":"host","hostId":"hogee"},"name":"chchch","status":"CRITICAL","message":"OKOK","occurredAt":100}]}` 25 | bs, _ := json.Marshal(crs) 26 | got := string(bs) 27 | 28 | if got != expect { 29 | t.Errorf("expect: %s, but: %s", expect, got) 30 | } 31 | } 32 | 33 | func TestClient_PostCheckReports(t *testing.T) { 34 | crs := &CheckReports{ 35 | Reports: []*CheckReport{ 36 | { 37 | Source: NewCheckSourceHost("hogee"), 38 | Name: "chchch", 39 | Status: CheckStatusCritical, 40 | OccurredAt: 100, 41 | Message: "OKOK", 42 | }, 43 | }, 44 | } 45 | 46 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 47 | reqPath := "/api/v0/monitoring/checks/report" 48 | if req.URL.Path != reqPath { 49 | t.Errorf("request URL should be %s but: %s", reqPath, req.URL.Path) 50 | } 51 | 52 | if req.Method != "POST" { 53 | t.Error("request method should be POST but: ", req.Method) 54 | } 55 | 56 | body, _ := io.ReadAll(req.Body) 57 | 58 | var values struct { 59 | Reports []struct { 60 | Source interface{} `json:"source"` 61 | Name string `json:"name"` 62 | Status CheckStatus `json:"status"` 63 | Message string `json:"message"` 64 | OccurredAt int64 `json:"occurredAt"` 65 | } `json:"reports"` 66 | } 67 | 68 | err := json.Unmarshal(body, &values) 69 | if err != nil { 70 | t.Fatal("request body should be decoded as json", string(body)) 71 | } 72 | 73 | r := values.Reports[0] 74 | if r.Name != "chchch" { 75 | t.Error("request sends json including hostId but: ", r.Name) 76 | } 77 | if r.OccurredAt != 100 { 78 | t.Error("request sends json including time but: ", r.OccurredAt) 79 | } 80 | if r.Status != CheckStatusCritical { 81 | t.Error("request sends json including value but: ", r.Status) 82 | } 83 | if r.Message != "OKOK" { 84 | t.Error("request sends json including value but: ", r.Message) 85 | } 86 | 87 | respJSON, _ := json.Marshal(map[string]bool{ 88 | "success": true, 89 | }) 90 | res.Header()["Content-Type"] = []string{"application/json"} 91 | fmt.Fprint(res, string(respJSON)) 92 | })) 93 | defer ts.Close() 94 | 95 | cli, _ := NewClientWithOptions("dummy-key", ts.URL, false) 96 | err := cli.PostCheckReports(crs) 97 | 98 | if err != nil { 99 | t.Error("err should be nil but: ", err) 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /dashboards.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | /* 9 | `/dashboards` Response 10 | { 11 | "dashboards": [ 12 | { 13 | "id": "2c5bLca8e", 14 | "title": "My Custom Dashboard(Current)", 15 | "urlPath": "2u4PP3TJqbv", 16 | "createdAt": 1552909732, 17 | "updatedAt": 1552992837, 18 | "memo": "A test Current Dashboard" 19 | } 20 | ] 21 | } 22 | */ 23 | 24 | /* 25 | `/dashboards/${ID}` Response` 26 | { 27 | "id": "2c5bLca8e", 28 | "createdAt": 1552909732, 29 | "updatedAt": 1552992837, 30 | "title": "My Custom Dashboard(Current), 31 | "urlPath": "2u4PP3TJqbv", 32 | "memo": "A test Current Dashboard", 33 | "widgets": [ 34 | { 35 | "type": "markdown", 36 | "title": "markdown", 37 | "markdown": "# body", 38 | "layout": { 39 | "x": 0, 40 | "y": 0, 41 | "width": 24, 42 | "height": 3 43 | } 44 | }, 45 | { 46 | "type": "graph", 47 | "title": "graph", 48 | "graph": { 49 | "type": "host", 50 | "hostId": "2u4PP3TJqbw", 51 | "name": "loadavg.loadavg15" 52 | }, 53 | "layout": { 54 | "x": 0, 55 | "y": 7, 56 | "width": 8, 57 | "height": 10 58 | } 59 | }, 60 | { 61 | "type": "value", 62 | "title": "value", 63 | "fractionSize": 2, 64 | "suffix": "total", 65 | "metric": { 66 | "type": "expression", 67 | "expression": "alias(scale(\nsum(\n group(\n host(2u4PP3TJqbx,loadavg.*)\n )\n),\n1\n), 'test')" 68 | }, 69 | "layout": { 70 | "x": 0, 71 | "y": 17, 72 | "width": 8, 73 | "height": 5 74 | } 75 | }, 76 | { 77 | "type": "alertStatus", 78 | "title": "alertStatus", 79 | "roleFullname": "test:dashboard", 80 | "layout": { 81 | "x": 0, 82 | "y": 17, 83 | "width": 8, 84 | "height": 5 85 | } 86 | } 87 | ] 88 | } 89 | */ 90 | 91 | // Dashboard information 92 | type Dashboard struct { 93 | ID string `json:"id,omitempty"` 94 | Title string `json:"title"` 95 | URLPath string `json:"urlPath"` 96 | CreatedAt int64 `json:"createdAt,omitempty"` 97 | UpdatedAt int64 `json:"updatedAt,omitempty"` 98 | Memo string `json:"memo"` 99 | Widgets []Widget `json:"widgets"` 100 | } 101 | 102 | // Widget information 103 | type Widget struct { 104 | Type string `json:"type"` 105 | Title string `json:"title"` 106 | Layout Layout `json:"layout"` 107 | Metric Metric `json:"metric,omitempty"` 108 | Graph Graph `json:"graph,omitempty"` 109 | Range Range `json:"range,omitempty"` 110 | Markdown string `json:"markdown,omitempty"` 111 | ReferenceLines []ReferenceLine `json:"referenceLines,omitempty"` 112 | // If this field is nil, it will be treated as a two-digit display after the decimal point. 113 | FractionSize *int64 `json:"fractionSize,omitempty"` 114 | Suffix string `json:"suffix,omitempty"` 115 | FormatRules []FormatRule `json:"formatRules,omitempty"` 116 | RoleFullName string `json:"roleFullname,omitempty"` 117 | } 118 | 119 | // Metric information 120 | type Metric struct { 121 | Type string `json:"type"` 122 | Name string `json:"name,omitempty"` 123 | HostID string `json:"hostId,omitempty"` 124 | ServiceName string `json:"serviceName,omitempty"` 125 | Expression string `json:"expression,omitempty"` 126 | Query string `json:"query,omitempty"` 127 | Legend string `json:"legend,omitempty"` 128 | } 129 | type metricQuery struct { 130 | Type string `json:"type"` 131 | Name string `json:"-"` 132 | HostID string `json:"-"` 133 | ServiceName string `json:"-"` 134 | Expression string `json:"-"` 135 | Query string `json:"query"` 136 | Legend string `json:"legend"` 137 | } 138 | 139 | // MarshalJSON marshals as JSON 140 | func (m Metric) MarshalJSON() ([]byte, error) { 141 | type Alias Metric 142 | switch m.Type { 143 | case "": 144 | return []byte("null"), nil 145 | case "query": 146 | return json.Marshal(metricQuery(m)) 147 | default: 148 | return json.Marshal(Alias(m)) 149 | } 150 | } 151 | 152 | // FormatRule information 153 | type FormatRule struct { 154 | Name string `json:"name"` 155 | Threshold float64 `json:"threshold"` 156 | Operator string `json:"operator"` 157 | } 158 | 159 | // Graph information 160 | type Graph struct { 161 | Type string `json:"type"` 162 | Name string `json:"name,omitempty"` 163 | HostID string `json:"hostId,omitempty"` 164 | RoleFullName string `json:"roleFullname,omitempty"` 165 | IsStacked bool `json:"isStacked,omitempty"` 166 | ServiceName string `json:"serviceName,omitempty"` 167 | Expression string `json:"expression,omitempty"` 168 | Query string `json:"query,omitempty"` 169 | Legend string `json:"legend,omitempty"` 170 | } 171 | type graphQuery struct { 172 | Type string `json:"type"` 173 | Name string `json:"-"` 174 | HostID string `json:"-"` 175 | RoleFullName string `json:"-"` 176 | IsStacked bool `json:"-"` 177 | ServiceName string `json:"-"` 178 | Expression string `json:"-"` 179 | Query string `json:"query"` 180 | Legend string `json:"legend"` 181 | } 182 | 183 | // MarshalJSON marshals as JSON 184 | func (g Graph) MarshalJSON() ([]byte, error) { 185 | type Alias Graph 186 | switch g.Type { 187 | case "": 188 | return []byte("null"), nil 189 | case "query": 190 | return json.Marshal(graphQuery(g)) 191 | default: 192 | return json.Marshal(Alias(g)) 193 | } 194 | } 195 | 196 | // Range information 197 | type Range struct { 198 | Type string `json:"type"` 199 | Period int64 `json:"period,omitempty"` 200 | Offset int64 `json:"offset,omitempty"` 201 | Start int64 `json:"start,omitempty"` 202 | End int64 `json:"end,omitempty"` 203 | } 204 | 205 | type rangeAbsolute struct { 206 | Type string `json:"type"` 207 | Period int64 `json:"-"` 208 | Offset int64 `json:"-"` 209 | Start int64 `json:"start"` 210 | End int64 `json:"end"` 211 | } 212 | 213 | type rangeRelative struct { 214 | Type string `json:"type"` 215 | Period int64 `json:"period"` 216 | Offset int64 `json:"offset"` 217 | Start int64 `json:"-"` 218 | End int64 `json:"-"` 219 | } 220 | 221 | // MarshalJSON marshals as JSON 222 | func (r Range) MarshalJSON() ([]byte, error) { 223 | switch r.Type { 224 | case "absolute": 225 | return json.Marshal(rangeAbsolute(r)) 226 | case "relative": 227 | return json.Marshal(rangeRelative(r)) 228 | default: 229 | return []byte("null"), nil 230 | } 231 | } 232 | 233 | // ReferenceLine information 234 | type ReferenceLine struct { 235 | Label string `json:"label"` 236 | Value float64 `json:"value"` 237 | } 238 | 239 | // Layout information 240 | type Layout struct { 241 | X int64 `json:"x"` 242 | Y int64 `json:"y"` 243 | Width int64 `json:"width"` 244 | Height int64 `json:"height"` 245 | } 246 | 247 | // FindDashboards finds dashboards. 248 | func (c *Client) FindDashboards() ([]*Dashboard, error) { 249 | data, err := requestGet[struct { 250 | Dashboards []*Dashboard `json:"dashboards"` 251 | }](c, "/api/v0/dashboards") 252 | if err != nil { 253 | return nil, err 254 | } 255 | return data.Dashboards, nil 256 | } 257 | 258 | // CreateDashboard creates a dashboard. 259 | func (c *Client) CreateDashboard(param *Dashboard) (*Dashboard, error) { 260 | return requestPost[Dashboard](c, "/api/v0/dashboards", param) 261 | } 262 | 263 | // FindDashboard finds a dashboard. 264 | func (c *Client) FindDashboard(dashboardID string) (*Dashboard, error) { 265 | path := fmt.Sprintf("/api/v0/dashboards/%s", dashboardID) 266 | return requestGet[Dashboard](c, path) 267 | } 268 | 269 | // UpdateDashboard updates a dashboard. 270 | func (c *Client) UpdateDashboard(dashboardID string, param *Dashboard) (*Dashboard, error) { 271 | path := fmt.Sprintf("/api/v0/dashboards/%s", dashboardID) 272 | return requestPut[Dashboard](c, path, param) 273 | } 274 | 275 | // DeleteDashboard deletes a dashboard. 276 | func (c *Client) DeleteDashboard(dashboardID string) (*Dashboard, error) { 277 | path := fmt.Sprintf("/api/v0/dashboards/%s", dashboardID) 278 | return requestDelete[Dashboard](c, path) 279 | } 280 | -------------------------------------------------------------------------------- /downtimes.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // Downtime information 10 | type Downtime struct { 11 | ID string `json:"id,omitempty"` 12 | Name string `json:"name"` 13 | Memo string `json:"memo,omitempty"` 14 | Start int64 `json:"start"` 15 | Duration int64 `json:"duration"` 16 | Recurrence *DowntimeRecurrence `json:"recurrence,omitempty"` 17 | ServiceScopes []string `json:"serviceScopes,omitempty"` 18 | ServiceExcludeScopes []string `json:"serviceExcludeScopes,omitempty"` 19 | RoleScopes []string `json:"roleScopes,omitempty"` 20 | RoleExcludeScopes []string `json:"roleExcludeScopes,omitempty"` 21 | MonitorScopes []string `json:"monitorScopes,omitempty"` 22 | MonitorExcludeScopes []string `json:"monitorExcludeScopes,omitempty"` 23 | } 24 | 25 | // DowntimeRecurrence ... 26 | type DowntimeRecurrence struct { 27 | Type DowntimeRecurrenceType `json:"type"` 28 | Interval int64 `json:"interval"` 29 | Weekdays []DowntimeWeekday `json:"weekdays,omitempty"` 30 | Until int64 `json:"until,omitempty"` 31 | } 32 | 33 | // DowntimeRecurrenceType ... 34 | type DowntimeRecurrenceType int 35 | 36 | // DowntimeRecurrenceType ... 37 | const ( 38 | DowntimeRecurrenceTypeHourly DowntimeRecurrenceType = iota + 1 39 | DowntimeRecurrenceTypeDaily 40 | DowntimeRecurrenceTypeWeekly 41 | DowntimeRecurrenceTypeMonthly 42 | DowntimeRecurrenceTypeYearly 43 | ) 44 | 45 | var stringToRecurrenceType = map[string]DowntimeRecurrenceType{ 46 | "hourly": DowntimeRecurrenceTypeHourly, 47 | "daily": DowntimeRecurrenceTypeDaily, 48 | "weekly": DowntimeRecurrenceTypeWeekly, 49 | "monthly": DowntimeRecurrenceTypeMonthly, 50 | "yearly": DowntimeRecurrenceTypeYearly, 51 | } 52 | 53 | var recurrenceTypeToString = map[DowntimeRecurrenceType]string{ 54 | DowntimeRecurrenceTypeHourly: "hourly", 55 | DowntimeRecurrenceTypeDaily: "daily", 56 | DowntimeRecurrenceTypeWeekly: "weekly", 57 | DowntimeRecurrenceTypeMonthly: "monthly", 58 | DowntimeRecurrenceTypeYearly: "yearly", 59 | } 60 | 61 | // UnmarshalJSON implements json.Unmarshaler 62 | func (t *DowntimeRecurrenceType) UnmarshalJSON(b []byte) error { 63 | var s string 64 | if err := json.Unmarshal(b, &s); err != nil { 65 | return err 66 | } 67 | if x, ok := stringToRecurrenceType[s]; ok { 68 | *t = x 69 | return nil 70 | } 71 | return fmt.Errorf("unknown downtime recurrence type: %q", s) 72 | } 73 | 74 | // MarshalJSON implements json.Marshaler 75 | func (t DowntimeRecurrenceType) MarshalJSON() ([]byte, error) { 76 | return json.Marshal(recurrenceTypeToString[t]) 77 | } 78 | 79 | // String implements Stringer 80 | func (t DowntimeRecurrenceType) String() string { 81 | return recurrenceTypeToString[t] 82 | } 83 | 84 | // DowntimeWeekday ... 85 | type DowntimeWeekday time.Weekday 86 | 87 | var stringToWeekday = map[string]DowntimeWeekday{ 88 | "Sunday": DowntimeWeekday(time.Sunday), 89 | "Monday": DowntimeWeekday(time.Monday), 90 | "Tuesday": DowntimeWeekday(time.Tuesday), 91 | "Wednesday": DowntimeWeekday(time.Wednesday), 92 | "Thursday": DowntimeWeekday(time.Thursday), 93 | "Friday": DowntimeWeekday(time.Friday), 94 | "Saturday": DowntimeWeekday(time.Saturday), 95 | } 96 | 97 | var weekdayToString = map[DowntimeWeekday]string{ 98 | DowntimeWeekday(time.Sunday): "Sunday", 99 | DowntimeWeekday(time.Monday): "Monday", 100 | DowntimeWeekday(time.Tuesday): "Tuesday", 101 | DowntimeWeekday(time.Wednesday): "Wednesday", 102 | DowntimeWeekday(time.Thursday): "Thursday", 103 | DowntimeWeekday(time.Friday): "Friday", 104 | DowntimeWeekday(time.Saturday): "Saturday", 105 | } 106 | 107 | // UnmarshalJSON implements json.Unmarshaler 108 | func (w *DowntimeWeekday) UnmarshalJSON(b []byte) error { 109 | var s string 110 | if err := json.Unmarshal(b, &s); err != nil { 111 | return err 112 | } 113 | if x, ok := stringToWeekday[s]; ok { 114 | *w = x 115 | return nil 116 | } 117 | return fmt.Errorf("unknown downtime weekday: %q", s) 118 | } 119 | 120 | // MarshalJSON implements json.Marshaler 121 | func (w DowntimeWeekday) MarshalJSON() ([]byte, error) { 122 | return json.Marshal(weekdayToString[w]) 123 | } 124 | 125 | // String implements Stringer 126 | func (w DowntimeWeekday) String() string { 127 | return weekdayToString[w] 128 | } 129 | 130 | // FindDowntimes finds downtimes. 131 | func (c *Client) FindDowntimes() ([]*Downtime, error) { 132 | data, err := requestGet[struct { 133 | Downtimes []*Downtime `json:"downtimes"` 134 | }](c, "/api/v0/downtimes") 135 | if err != nil { 136 | return nil, err 137 | } 138 | return data.Downtimes, nil 139 | } 140 | 141 | // CreateDowntime creates a downtime. 142 | func (c *Client) CreateDowntime(param *Downtime) (*Downtime, error) { 143 | return requestPost[Downtime](c, "/api/v0/downtimes", param) 144 | } 145 | 146 | // UpdateDowntime updates a downtime. 147 | func (c *Client) UpdateDowntime(downtimeID string, param *Downtime) (*Downtime, error) { 148 | path := fmt.Sprintf("/api/v0/downtimes/%s", downtimeID) 149 | return requestPut[Downtime](c, path, param) 150 | } 151 | 152 | // DeleteDowntime deletes a downtime. 153 | func (c *Client) DeleteDowntime(downtimeID string) (*Downtime, error) { 154 | path := fmt.Sprintf("/api/v0/downtimes/%s", downtimeID) 155 | return requestDelete[Downtime](c, path) 156 | } 157 | -------------------------------------------------------------------------------- /downtimes_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "reflect" 10 | "testing" 11 | "time" 12 | 13 | "github.com/kylelemons/godebug/pretty" 14 | ) 15 | 16 | func TestFindDowntimes(t *testing.T) { 17 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 18 | if req.URL.Path != "/api/v0/downtimes" { 19 | t.Error("request URL should be /api/v0/downtimes but: ", req.URL.Path) 20 | } 21 | 22 | respJSON, _ := json.Marshal(map[string]interface{}{ 23 | "downtimes": []interface{}{ 24 | map[string]interface{}{ 25 | "id": "abcde0", 26 | "name": "Maintenance #0", 27 | "memo": "Memo #0", 28 | "start": 1563600000, 29 | "duration": 120, 30 | }, 31 | map[string]interface{}{ 32 | "id": "abcde1", 33 | "name": "Maintenance #1", 34 | "memo": "Memo #1", 35 | "start": 1563700000, 36 | "duration": 60, 37 | "recurrence": map[string]interface{}{ 38 | "interval": 3, 39 | "type": "weekly", 40 | "weekdays": []string{ 41 | "Monday", 42 | "Thursday", 43 | "Saturday", 44 | }, 45 | }, 46 | "serviceScopes": []string{ 47 | "service1", 48 | }, 49 | "serviceExcludeScopes": []string{ 50 | "service2", 51 | }, 52 | "roleScopes": []string{ 53 | "service3: role1", 54 | }, 55 | "roleExcludeScopes": []string{ 56 | "service1: role1", 57 | }, 58 | "monitorScopes": []string{ 59 | "monitor0", 60 | }, 61 | "monitorExcludeScopes": []string{ 62 | "monitor1", 63 | }, 64 | }}, 65 | }) 66 | 67 | res.Header()["Content-Type"] = []string{"application/json"} 68 | fmt.Fprint(res, string(respJSON)) 69 | })) 70 | defer ts.Close() 71 | 72 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 73 | downtimes, err := client.FindDowntimes() 74 | 75 | if err != nil { 76 | t.Error("err should be nil but: ", err) 77 | } 78 | 79 | expected := []*Downtime{ 80 | { 81 | ID: "abcde0", 82 | Name: "Maintenance #0", 83 | Memo: "Memo #0", 84 | Start: 1563600000, 85 | Duration: 120, 86 | }, 87 | { 88 | ID: "abcde1", 89 | Name: "Maintenance #1", 90 | Memo: "Memo #1", 91 | Start: 1563700000, 92 | Duration: 60, 93 | Recurrence: &DowntimeRecurrence{ 94 | Type: DowntimeRecurrenceTypeWeekly, 95 | Interval: 3, 96 | Weekdays: []DowntimeWeekday{ 97 | DowntimeWeekday(time.Monday), 98 | DowntimeWeekday(time.Thursday), 99 | DowntimeWeekday(time.Saturday), 100 | }, 101 | }, 102 | ServiceScopes: []string{ 103 | "service1", 104 | }, 105 | ServiceExcludeScopes: []string{ 106 | "service2", 107 | }, 108 | RoleScopes: []string{ 109 | "service3: role1", 110 | }, 111 | RoleExcludeScopes: []string{ 112 | "service1: role1", 113 | }, 114 | MonitorScopes: []string{ 115 | "monitor0", 116 | }, 117 | MonitorExcludeScopes: []string{ 118 | "monitor1", 119 | }, 120 | }, 121 | } 122 | 123 | if !reflect.DeepEqual(downtimes, expected) { 124 | t.Errorf("fail to get correct data: diff: (-got +want)\n%v", pretty.Compare(downtimes, expected)) 125 | } 126 | } 127 | 128 | func TestCreateDowntime(t *testing.T) { 129 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 130 | if req.URL.Path != "/api/v0/downtimes" { 131 | t.Error("request URL should be /api/v0/downtimes but: ", req.URL.Path) 132 | } 133 | 134 | if req.Method != "POST" { 135 | t.Error("request method should be POST but: ", req.Method) 136 | } 137 | 138 | body, _ := io.ReadAll(req.Body) 139 | 140 | var d Downtime 141 | err := json.Unmarshal(body, &d) 142 | if err != nil { 143 | t.Fatal("request body should be decoded as downtime", string(body)) 144 | } 145 | 146 | respJSON, _ := json.Marshal(map[string]interface{}{ 147 | "id": "abcde", 148 | "name": "My maintenance", 149 | "memo": "This is a memo", 150 | "start": 1563700000, 151 | "duration": 30, 152 | "recurrence": map[string]interface{}{ 153 | "type": "daily", 154 | "interval": 2, 155 | "until": 1573700000, 156 | }, 157 | }) 158 | 159 | res.Header()["Content-Type"] = []string{"application/json"} 160 | fmt.Fprint(res, string(respJSON)) 161 | })) 162 | defer ts.Close() 163 | 164 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 165 | 166 | downtime, err := client.CreateDowntime(&Downtime{ 167 | Name: "My maintenance", 168 | Memo: "This is a memo", 169 | Start: 1563700000, 170 | Duration: 30, 171 | Recurrence: &DowntimeRecurrence{ 172 | Type: DowntimeRecurrenceTypeDaily, 173 | Interval: 2, 174 | Until: 1573700000, 175 | }, 176 | }) 177 | 178 | if err != nil { 179 | t.Error("err should be nil but: ", err) 180 | } 181 | 182 | expected := &Downtime{ 183 | ID: "abcde", 184 | Name: "My maintenance", 185 | Memo: "This is a memo", 186 | Start: 1563700000, 187 | Duration: 30, 188 | Recurrence: &DowntimeRecurrence{ 189 | Type: DowntimeRecurrenceTypeDaily, 190 | Interval: 2, 191 | Until: 1573700000, 192 | }, 193 | } 194 | if !reflect.DeepEqual(downtime, expected) { 195 | t.Errorf("fail to get correct data: diff: (-got +want)\n%v", pretty.Compare(downtime, expected)) 196 | } 197 | } 198 | 199 | func TestUpdateDowntime(t *testing.T) { 200 | downtimeID := "abcde" 201 | 202 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 203 | if req.URL.Path != fmt.Sprintf("/api/v0/downtimes/%s", downtimeID) { 204 | t.Error("request URL should be /api/v0/downtimes/ but: ", req.URL.Path) 205 | } 206 | 207 | if req.Method != "PUT" { 208 | t.Error("request method should be PUT but: ", req.Method) 209 | } 210 | 211 | body, _ := io.ReadAll(req.Body) 212 | 213 | var d Downtime 214 | err := json.Unmarshal(body, &d) 215 | if err != nil { 216 | t.Fatal("request body should be decoded as json", string(body)) 217 | } 218 | 219 | respJSON, _ := json.Marshal(map[string]interface{}{ 220 | "id": "abcde", 221 | "name": "Updated maintenance", 222 | "memo": "This is a memo", 223 | "start": 1563700000, 224 | "duration": 30, 225 | "serviceScopes": []string{ 226 | "service1", 227 | "service2", 228 | }, 229 | "roleExcludeScopes": []string{ 230 | "service1: role1", 231 | "service2: role2", 232 | }, 233 | }) 234 | 235 | res.Header()["Content-Type"] = []string{"application/json"} 236 | fmt.Fprint(res, string(respJSON)) 237 | })) 238 | defer ts.Close() 239 | 240 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 241 | 242 | downtime, err := client.UpdateDowntime(downtimeID, &Downtime{ 243 | Name: "Updated maintenance", 244 | Memo: "This is a memo", 245 | Start: 1563700000, 246 | Duration: 30, 247 | ServiceScopes: []string{ 248 | "service1", 249 | "service2", 250 | }, 251 | RoleExcludeScopes: []string{ 252 | "service1: role1", 253 | "service2: role2", 254 | }, 255 | }) 256 | 257 | if err != nil { 258 | t.Error("err should be nil but: ", err) 259 | } 260 | 261 | expected := &Downtime{ 262 | ID: "abcde", 263 | Name: "Updated maintenance", 264 | Memo: "This is a memo", 265 | Start: 1563700000, 266 | Duration: 30, 267 | ServiceScopes: []string{ 268 | "service1", 269 | "service2", 270 | }, 271 | RoleExcludeScopes: []string{ 272 | "service1: role1", 273 | "service2: role2", 274 | }, 275 | } 276 | if !reflect.DeepEqual(downtime, expected) { 277 | t.Errorf("fail to get correct data: diff: (-got +want)\n%v", pretty.Compare(downtime, expected)) 278 | } 279 | } 280 | 281 | func TestDeleteDowntime(t *testing.T) { 282 | downtimeID := "abcde" 283 | 284 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 285 | if req.URL.Path != fmt.Sprintf("/api/v0/downtimes/%s", downtimeID) { 286 | t.Error("request URL should be /api/v0/downtimes/ but: ", req.URL.Path) 287 | } 288 | 289 | if req.Method != "DELETE" { 290 | t.Error("request method should be DELETE but: ", req.Method) 291 | } 292 | 293 | respJSON, _ := json.Marshal(map[string]interface{}{ 294 | "id": "abcde", 295 | "name": "My maintenance", 296 | "memo": "This is a memo", 297 | "start": 1563700000, 298 | "duration": 60, 299 | }) 300 | 301 | res.Header()["Content-Type"] = []string{"application/json"} 302 | fmt.Fprint(res, string(respJSON)) 303 | })) 304 | defer ts.Close() 305 | 306 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 307 | 308 | downtime, err := client.DeleteDowntime(downtimeID) 309 | 310 | if err != nil { 311 | t.Error("err should be nil but: ", err) 312 | } 313 | 314 | expected := &Downtime{ 315 | ID: "abcde", 316 | Name: "My maintenance", 317 | Memo: "This is a memo", 318 | Start: 1563700000, 319 | Duration: 60, 320 | } 321 | if !reflect.DeepEqual(downtime, expected) { 322 | t.Errorf("fail to get correct data: diff: (-got +want)\n%v", pretty.Compare(downtime, expected)) 323 | } 324 | } 325 | 326 | var downtimeRecurrenceTestCases = []struct { 327 | title string 328 | recurrence *DowntimeRecurrence 329 | json string 330 | }{ 331 | { 332 | "hourly", 333 | &DowntimeRecurrence{ 334 | Type: DowntimeRecurrenceTypeHourly, 335 | Interval: 1, 336 | }, 337 | `{ 338 | "type": "hourly", 339 | "interval": 1 340 | }`, 341 | }, 342 | { 343 | "daily", 344 | &DowntimeRecurrence{ 345 | Type: DowntimeRecurrenceTypeDaily, 346 | Interval: 3, 347 | Until: 1573730000, 348 | }, 349 | `{ 350 | "type": "daily", 351 | "interval": 3, 352 | "until": 1573730000 353 | }`, 354 | }, 355 | { 356 | "weekly", 357 | &DowntimeRecurrence{ 358 | Type: DowntimeRecurrenceTypeWeekly, 359 | Interval: 2, 360 | Weekdays: []DowntimeWeekday{ 361 | DowntimeWeekday(time.Sunday), 362 | DowntimeWeekday(time.Monday), 363 | DowntimeWeekday(time.Tuesday), 364 | DowntimeWeekday(time.Wednesday), 365 | DowntimeWeekday(time.Thursday), 366 | DowntimeWeekday(time.Friday), 367 | DowntimeWeekday(time.Saturday), 368 | }, 369 | }, 370 | `{ 371 | "type": "weekly", 372 | "interval": 2, 373 | "weekdays": [ 374 | "Sunday", 375 | "Monday", 376 | "Tuesday", 377 | "Wednesday", 378 | "Thursday", 379 | "Friday", 380 | "Saturday" 381 | ] 382 | }`, 383 | }, 384 | { 385 | "monthly", 386 | &DowntimeRecurrence{ 387 | Type: DowntimeRecurrenceTypeMonthly, 388 | Interval: 2, 389 | }, 390 | `{ 391 | "type": "monthly", 392 | "interval": 2 393 | }`, 394 | }, 395 | { 396 | "yearly", 397 | &DowntimeRecurrence{ 398 | Type: DowntimeRecurrenceTypeYearly, 399 | Interval: 1, 400 | }, 401 | `{ 402 | "type": "yearly", 403 | "interval": 1 404 | }`, 405 | }, 406 | } 407 | 408 | func TestDecodeEncodeDowntimeRecurrence(t *testing.T) { 409 | for _, testCase := range downtimeRecurrenceTestCases { 410 | r := new(DowntimeRecurrence) 411 | err := json.Unmarshal([]byte(testCase.json), r) 412 | if err != nil { 413 | t.Errorf("%s: err should be nil but: %v", testCase.title, err) 414 | } 415 | if !reflect.DeepEqual(r, testCase.recurrence) { 416 | t.Errorf("%s: fail to get correct data: diff: (-got +want)\n%v", testCase.title, pretty.Compare(r, testCase.recurrence)) 417 | } 418 | 419 | b, err := json.MarshalIndent(&testCase.recurrence, "", " ") 420 | if err != nil { 421 | t.Errorf("%s: err should be nil but: %v", testCase.title, err) 422 | } 423 | if gotJSON := string(b); !equalJSON(gotJSON, testCase.json) { 424 | t.Errorf("%s: got %v, want %v", testCase.title, gotJSON, testCase.json) 425 | } 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | // APIError represents the error type from Mackerel API. 10 | type APIError struct { 11 | StatusCode int 12 | Message string 13 | } 14 | 15 | func (err *APIError) Error() string { 16 | return fmt.Sprintf("API request failed: %s", err.Message) 17 | } 18 | 19 | func extractErrorMessage(r io.Reader) (errorMessage string, err error) { 20 | bs, err := io.ReadAll(r) 21 | if err != nil { 22 | return "", err 23 | } 24 | var data struct{ Error struct{ Message string } } 25 | err = json.Unmarshal(bs, &data) 26 | if err != nil { 27 | return "", err 28 | } 29 | return data.Error.Message, nil 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mackerelio/mackerel-client-go 2 | 3 | go 1.21.0 4 | 5 | require github.com/kylelemons/godebug v1.1.0 6 | 7 | retract v0.33.0 // API endpoint for DeleteGraphDef has changed. 8 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 2 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 3 | -------------------------------------------------------------------------------- /graph_annotation.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | ) 8 | 9 | // GraphAnnotation represents parameters to post a graph annotation. 10 | type GraphAnnotation struct { 11 | ID string `json:"id,omitempty"` 12 | Title string `json:"title,omitempty"` 13 | Description string `json:"description,omitempty"` 14 | From int64 `json:"from,omitempty"` 15 | To int64 `json:"to,omitempty"` 16 | Service string `json:"service,omitempty"` 17 | Roles []string `json:"roles,omitempty"` 18 | } 19 | 20 | // FindGraphAnnotations fetches graph annotations. 21 | func (c *Client) FindGraphAnnotations(service string, from int64, to int64) ([]*GraphAnnotation, error) { 22 | params := url.Values{} 23 | params.Add("service", service) 24 | params.Add("from", strconv.FormatInt(from, 10)) 25 | params.Add("to", strconv.FormatInt(to, 10)) 26 | 27 | data, err := requestGetWithParams[struct { 28 | GraphAnnotations []*GraphAnnotation `json:"graphAnnotations"` 29 | }](c, "/api/v0/graph-annotations", params) 30 | if err != nil { 31 | return nil, err 32 | } 33 | return data.GraphAnnotations, nil 34 | } 35 | 36 | // CreateGraphAnnotation creates a graph annotation. 37 | func (c *Client) CreateGraphAnnotation(annotation *GraphAnnotation) (*GraphAnnotation, error) { 38 | return requestPost[GraphAnnotation](c, "/api/v0/graph-annotations", annotation) 39 | } 40 | 41 | // UpdateGraphAnnotation updates a graph annotation. 42 | func (c *Client) UpdateGraphAnnotation(annotationID string, annotation *GraphAnnotation) (*GraphAnnotation, error) { 43 | path := fmt.Sprintf("/api/v0/graph-annotations/%s", annotationID) 44 | return requestPut[GraphAnnotation](c, path, annotation) 45 | } 46 | 47 | // DeleteGraphAnnotation deletes a graph annotation. 48 | func (c *Client) DeleteGraphAnnotation(annotationID string) (*GraphAnnotation, error) { 49 | path := fmt.Sprintf("/api/v0/graph-annotations/%s", annotationID) 50 | return requestDelete[GraphAnnotation](c, path) 51 | } 52 | -------------------------------------------------------------------------------- /graph_annotation_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "reflect" 10 | "testing" 11 | ) 12 | 13 | func TestCreateGraphAnnotation(t *testing.T) { 14 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 15 | if req.URL.Path != "/api/v0/graph-annotations" { 16 | t.Error("request URL should be /api/v0/graph-annotations but: ", req.URL.Path) 17 | } 18 | 19 | if req.Method != "POST" { 20 | t.Error("request method should be POST but: ", req.Method) 21 | } 22 | body, _ := io.ReadAll(req.Body) 23 | 24 | var data struct { 25 | Service string `json:"service,omitempty"` 26 | Roles []string `json:"roles,omitempty"` 27 | From int64 `json:"from,omitempty"` 28 | To int64 `json:"to,omitempty"` 29 | Title string `json:"title,omitempty"` 30 | Description string `json:"description,omitempty"` 31 | } 32 | 33 | err := json.Unmarshal(body, &data) 34 | if err != nil { 35 | t.Fatal("request body should be decoded as json", string(body)) 36 | } 37 | if data.Service != "My-Blog" { 38 | t.Errorf("request sends json including Service but: %s", data.Service) 39 | } 40 | if !reflect.DeepEqual(data.Roles, []string{"Role1", "Role2"}) { 41 | t.Error("request sends json including Roles but: ", data.Roles) 42 | } 43 | if data.From != 1485675275 { 44 | t.Errorf("request sends json including From but: %d", data.From) 45 | } 46 | if data.To != 1485675299 { 47 | t.Errorf("request sends json including To but: %d", data.To) 48 | } 49 | if data.Title != "Deployed" { 50 | t.Errorf("request sends json including Title but: %s", data.Title) 51 | } 52 | if data.Description != "Deployed my blog" { 53 | t.Errorf("request sends json including Description but: %s", data.Description) 54 | } 55 | respJSON, _ := json.Marshal(map[string]interface{}{ 56 | "service": "My-Blog", 57 | "roles": []string{"Role1", "Role2"}, 58 | "from": 1485675275, 59 | "to": 1485675299, 60 | "title": "Deployed", 61 | "description": "Deployed my blog", 62 | }) 63 | res.Header()["Content-Type"] = []string{"application/json"} 64 | fmt.Fprint(res, string(respJSON)) 65 | })) 66 | defer ts.Close() 67 | 68 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 69 | annotation, err := client.CreateGraphAnnotation(&GraphAnnotation{ 70 | Service: "My-Blog", 71 | Roles: []string{"Role1", "Role2"}, 72 | From: 1485675275, 73 | To: 1485675299, 74 | Title: "Deployed", 75 | Description: "Deployed my blog", 76 | }) 77 | 78 | if err != nil { 79 | t.Error("err should be nil but: ", err) 80 | } 81 | 82 | if annotation.Service != "My-Blog" { 83 | t.Error("request sends json including Service but: ", annotation.Service) 84 | } 85 | 86 | if !reflect.DeepEqual(annotation.Roles, []string{"Role1", "Role2"}) { 87 | t.Error("request sends json including Roles but: ", annotation.Roles) 88 | } 89 | 90 | if annotation.From != 1485675275 { 91 | t.Error("request sends json including From but: ", annotation.From) 92 | } 93 | 94 | if annotation.To != 1485675299 { 95 | t.Error("request sends json including To but: ", annotation.To) 96 | } 97 | 98 | if annotation.Title != "Deployed" { 99 | t.Error("request sends json including Title but: ", annotation.Title) 100 | } 101 | 102 | if annotation.Description != "Deployed my blog" { 103 | t.Error("request sends json including Description but: ", annotation.Description) 104 | } 105 | } 106 | 107 | func TestFindGraphAnnotations(t *testing.T) { 108 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 109 | if req.URL.Path != "/api/v0/graph-annotations" { 110 | t.Error("request URL should be /api/v0/graph-annotations but: ", req.URL.Path) 111 | } 112 | if req.Method != "GET" { 113 | t.Error("request method should be GET but: ", req.Method) 114 | } 115 | 116 | query := req.URL.Query() 117 | if query.Get("service") != "My-Blog" { 118 | t.Error("request query 'service' param should be My-Blog but: ", query.Get("service")) 119 | } 120 | if query.Get("from") != "1485675275" { 121 | t.Error("request query 'from' param should be 1485675275 but: ", query.Get("from")) 122 | } 123 | if query.Get("to") != "1485675299" { 124 | t.Error("request query 'from' param should be 1485675299 but: ", query.Get("from")) 125 | } 126 | 127 | respJSON, _ := json.Marshal(map[string][]map[string]interface{}{ 128 | "graphAnnotations": { 129 | { 130 | "service": "My-Blog", 131 | "roles": []string{"Role1", "Role2"}, 132 | "from": 1485675275, 133 | "to": 1485675299, 134 | "title": "Deployed", 135 | "description": "Deployed my blog", 136 | }, 137 | }, 138 | }) 139 | 140 | res.Header()["Content-Type"] = []string{"application/json"} 141 | fmt.Fprint(res, string(respJSON)) 142 | })) 143 | defer ts.Close() 144 | 145 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 146 | annotations, err := client.FindGraphAnnotations("My-Blog", 1485675275, 1485675299) 147 | 148 | if err != nil { 149 | t.Error("err should be nil but: ", err) 150 | } 151 | 152 | if annotations[0].Service != "My-Blog" { 153 | t.Error("request sends json including Service but: ", annotations[0].Service) 154 | } 155 | 156 | if !reflect.DeepEqual(annotations[0].Roles, []string{"Role1", "Role2"}) { 157 | t.Error("request sends json including Roles but: ", annotations[0].Roles) 158 | } 159 | 160 | if annotations[0].From != 1485675275 { 161 | t.Error("request sends json including From but: ", annotations[0].From) 162 | } 163 | 164 | if annotations[0].To != 1485675299 { 165 | t.Error("request sends json including To but: ", annotations[0].To) 166 | } 167 | 168 | if annotations[0].Title != "Deployed" { 169 | t.Error("request sends json including Title but: ", annotations[0].Title) 170 | } 171 | 172 | if annotations[0].Description != "Deployed my blog" { 173 | t.Error("request sends json including Description but: ", annotations[0].Description) 174 | } 175 | } 176 | 177 | func TestUpdateGraphAnnotations(t *testing.T) { 178 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 179 | if req.URL.Path != "/api/v0/graph-annotations/123456789" { 180 | t.Error("request URL should be /api/v0/graph-annotations/123456789 but: ", req.URL.Path) 181 | } 182 | if req.Method != "PUT" { 183 | t.Error("request method should be PUT but: ", req.Method) 184 | } 185 | 186 | respJSON, _ := json.Marshal(map[string]interface{}{ 187 | "service": "My-Blog", 188 | "roles": []string{"Role1", "Role2"}, 189 | "from": 1485675275, 190 | "to": 1485675299, 191 | "title": "Deployed", 192 | "description": "Deployed my blog", 193 | }) 194 | 195 | res.Header()["Content-Type"] = []string{"application/json"} 196 | fmt.Fprint(res, string(respJSON)) 197 | })) 198 | defer ts.Close() 199 | 200 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 201 | annotation, err := client.UpdateGraphAnnotation("123456789", 202 | &GraphAnnotation{ 203 | Service: "My-Blog", 204 | Roles: []string{"Role1", "Role2"}, 205 | From: 1485675275, 206 | To: 1485675299, 207 | Title: "Deployed", 208 | Description: "Deployed my blog", 209 | }) 210 | 211 | if err != nil { 212 | t.Error("err should be nil but: ", err) 213 | } 214 | 215 | if annotation.Service != "My-Blog" { 216 | t.Error("request sends json including Service but: ", annotation.Service) 217 | } 218 | 219 | if !reflect.DeepEqual(annotation.Roles, []string{"Role1", "Role2"}) { 220 | t.Error("request sends json including Roles but: ", annotation.Roles) 221 | } 222 | 223 | if annotation.From != 1485675275 { 224 | t.Error("request sends json including From but: ", annotation.From) 225 | } 226 | 227 | if annotation.To != 1485675299 { 228 | t.Error("request sends json including To but: ", annotation.To) 229 | } 230 | 231 | if annotation.Title != "Deployed" { 232 | t.Error("request sends json including Title but: ", annotation.Title) 233 | } 234 | 235 | if annotation.Description != "Deployed my blog" { 236 | t.Error("request sends json including Description but: ", annotation.Description) 237 | } 238 | } 239 | 240 | func TestDeleteGraphAnnotations(t *testing.T) { 241 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 242 | if req.URL.Path != "/api/v0/graph-annotations/123456789" { 243 | t.Error("request URL should be /api/v0/graph-annotations/123456789 but: ", req.URL.Path) 244 | } 245 | if req.Method != "DELETE" { 246 | t.Error("request method should be DELETE but: ", req.Method) 247 | } 248 | 249 | respJSON, _ := json.Marshal(map[string]interface{}{ 250 | "service": "My-Blog", 251 | "roles": []string{"Role1", "Role2"}, 252 | "from": 1485675275, 253 | "to": 1485675299, 254 | "title": "Deployed", 255 | "description": "Deployed my blog", 256 | }) 257 | 258 | res.Header()["Content-Type"] = []string{"application/json"} 259 | fmt.Fprint(res, string(respJSON)) 260 | })) 261 | defer ts.Close() 262 | 263 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 264 | annotation, err := client.DeleteGraphAnnotation("123456789") 265 | 266 | if err != nil { 267 | t.Error("err should be nil but: ", err) 268 | } 269 | 270 | if annotation.Service != "My-Blog" { 271 | t.Error("request sends json including Service but: ", annotation.Service) 272 | } 273 | 274 | if !reflect.DeepEqual(annotation.Roles, []string{"Role1", "Role2"}) { 275 | t.Error("request sends json including Roles but: ", annotation.Roles) 276 | } 277 | 278 | if annotation.From != 1485675275 { 279 | t.Error("request sends json including From but: ", annotation.From) 280 | } 281 | 282 | if annotation.To != 1485675299 { 283 | t.Error("request sends json including To but: ", annotation.To) 284 | } 285 | 286 | if annotation.Title != "Deployed" { 287 | t.Error("request sends json including Title but: ", annotation.Title) 288 | } 289 | 290 | if annotation.Description != "Deployed my blog" { 291 | t.Error("request sends json including Description but: ", annotation.Description) 292 | } 293 | } 294 | 295 | func TestDeleteGraphAnnotations_NotFound(t *testing.T) { 296 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 297 | if req.URL.Path != "/api/v0/graph-annotations/123456789" { 298 | t.Error("request URL should be /api/v0/graph-annotations/123456789 but: ", req.URL.Path) 299 | } 300 | if req.Method != "DELETE" { 301 | t.Error("request method should be DELETE but: ", req.Method) 302 | } 303 | 304 | respJSON, _ := json.Marshal(map[string]map[string]string{ 305 | "error": {"message": "Graph annotation not found"}, 306 | }) 307 | 308 | res.Header()["Content-Type"] = []string{"application/json"} 309 | res.WriteHeader(http.StatusNotFound) 310 | fmt.Fprint(res, string(respJSON)) 311 | })) 312 | defer ts.Close() 313 | 314 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 315 | _, err := client.DeleteGraphAnnotation("123456789") 316 | 317 | if err == nil { 318 | t.Error("err should not be nil but: ", err) 319 | } 320 | 321 | apiErr := err.(*APIError) 322 | if expected := 404; apiErr.StatusCode != expected { 323 | t.Errorf("api error StatusCode should be %d but got: %d", expected, apiErr.StatusCode) 324 | } 325 | if expected := "API request failed: Graph annotation not found"; apiErr.Error() != expected { 326 | t.Errorf("api error string should be %s but got: %s", expected, apiErr.Error()) 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /graph_defs.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import "net/http" 4 | 5 | // GraphDefsParam parameters for posting graph definitions 6 | type GraphDefsParam struct { 7 | Name string `json:"name"` 8 | DisplayName string `json:"displayName,omitempty"` 9 | Unit string `json:"unit,omitempty"` 10 | Metrics []*GraphDefsMetric `json:"metrics"` 11 | } 12 | 13 | // GraphDefsMetric graph metric 14 | type GraphDefsMetric struct { 15 | Name string `json:"name"` 16 | DisplayName string `json:"displayName,omitempty"` 17 | IsStacked bool `json:"isStacked"` 18 | } 19 | 20 | // CreateGraphDefs creates graph definitions. 21 | func (c *Client) CreateGraphDefs(graphDefs []*GraphDefsParam) error { 22 | _, err := requestPost[any](c, "/api/v0/graph-defs/create", graphDefs) 23 | return err 24 | } 25 | 26 | // DeleteGraphDef deletes a graph definition. 27 | func (c *Client) DeleteGraphDef(name string) error { 28 | _, err := requestJSON[any](c, http.MethodDelete, "/api/v0/graph-defs", map[string]string{"name": name}) 29 | return err 30 | } 31 | -------------------------------------------------------------------------------- /graph_defs_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "reflect" 10 | "testing" 11 | ) 12 | 13 | func TestCreateGraphDefs(t *testing.T) { 14 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 15 | if req.URL.Path != "/api/v0/graph-defs/create" { 16 | t.Error("request URL should be /api/v0/graph-defs/create but: ", req.URL.Path) 17 | } 18 | 19 | if req.Method != "POST" { 20 | t.Error("request method should be POST but: ", req.Method) 21 | } 22 | body, _ := io.ReadAll(req.Body) 23 | 24 | var datas []struct { 25 | Name string `json:"name"` 26 | DisplayName string `json:"displayName"` 27 | Unit string `json:"unit"` 28 | Metrics []*GraphDefsMetric `json:"metrics"` 29 | } 30 | 31 | err := json.Unmarshal(body, &datas) 32 | if err != nil { 33 | t.Fatal("request body should be decoded as json", string(body)) 34 | } 35 | data := datas[0] 36 | 37 | if data.Name != "mackerel" { 38 | t.Errorf("request sends json including name but: %s", data.Name) 39 | } 40 | if data.DisplayName != "HorseMackerel" { 41 | t.Errorf("request sends json including DisplayName but: %s", data.Name) 42 | } 43 | if !reflect.DeepEqual( 44 | data.Metrics[0], 45 | &GraphDefsMetric{ 46 | Name: "saba1", 47 | DisplayName: "aji1", 48 | IsStacked: false, 49 | }, 50 | ) { 51 | t.Error("request sends json including GraphDefsMetric but: ", data.Metrics[0]) 52 | } 53 | respJSON, _ := json.Marshal(map[string]string{ 54 | "result": "OK", 55 | }) 56 | res.Header()["Content-Type"] = []string{"application/json"} 57 | fmt.Fprint(res, string(respJSON)) 58 | })) 59 | defer ts.Close() 60 | 61 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 62 | err := client.CreateGraphDefs([]*GraphDefsParam{ 63 | { 64 | Name: "mackerel", 65 | DisplayName: "HorseMackerel", 66 | Unit: "percentage", 67 | Metrics: []*GraphDefsMetric{ 68 | { 69 | Name: "saba1", 70 | DisplayName: "aji1", 71 | IsStacked: false, 72 | }, 73 | { 74 | Name: "saba2", 75 | DisplayName: "aji2", 76 | IsStacked: false, 77 | }, 78 | }, 79 | }, 80 | }) 81 | 82 | if err != nil { 83 | t.Error("err should be nil but: ", err) 84 | } 85 | } 86 | 87 | func TestGraphDefsOmitJSON(t *testing.T) { 88 | g := GraphDefsParam{ 89 | Metrics: []*GraphDefsMetric{ 90 | {}, 91 | }, 92 | } 93 | want := `{"name":"","metrics":[{"name":"","isStacked":false}]}` 94 | b, err := json.Marshal(&g) 95 | if err != nil { 96 | t.Fatal(err) 97 | } 98 | if s := string(b); s != want { 99 | t.Errorf("json.Marshal(%#v) = %q; want %q", g, s, want) 100 | } 101 | } 102 | 103 | func TestDeleteGraphDef(t *testing.T) { 104 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 105 | if req.URL.Path != "/api/v0/graph-defs" { 106 | t.Error("request URL should be /api/v0/graph-defs but:", req.URL.Path) 107 | } 108 | 109 | if req.Method != "DELETE" { 110 | t.Error("request method should be DELETE but: ", req.Method) 111 | } 112 | body, _ := io.ReadAll(req.Body) 113 | 114 | var data struct { 115 | Name string `json:"name"` 116 | } 117 | 118 | err := json.Unmarshal(body, &data) 119 | if err != nil { 120 | t.Fatal("request body should be decoded as json", string(body)) 121 | } 122 | 123 | if data.Name != "mackerel" { 124 | t.Errorf("request sends json including name but: %s", data.Name) 125 | } 126 | 127 | respJSON, _ := json.Marshal((map[string]bool{"success": true})) 128 | res.Header()["Content-Type"] = []string{"application/json"} 129 | fmt.Fprint(res, string(respJSON)) 130 | })) 131 | defer ts.Close() 132 | 133 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 134 | err := client.DeleteGraphDef("mackerel") 135 | 136 | if err != nil { 137 | t.Error("err should be nil but: ", err) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /host_metadata.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // https://mackerel.io/ja/api-docs/entry/metadata 10 | 11 | // HostMetaDataResp represents response for host metadata. 12 | type HostMetaDataResp struct { 13 | HostMetaData HostMetaData 14 | LastModified time.Time 15 | } 16 | 17 | // HostMetaData represents host metadata body. 18 | type HostMetaData interface{} 19 | 20 | // GetHostMetaData gets a host metadata. 21 | func (c *Client) GetHostMetaData(hostID, namespace string) (*HostMetaDataResp, error) { 22 | path := fmt.Sprintf("/api/v0/hosts/%s/metadata/%s", hostID, namespace) 23 | metadata, header, err := requestGetAndReturnHeader[HostMetaData](c, path) 24 | if err != nil { 25 | return nil, err 26 | } 27 | lastModified, err := http.ParseTime(header.Get("Last-Modified")) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &HostMetaDataResp{HostMetaData: *metadata, LastModified: lastModified}, nil 32 | } 33 | 34 | // GetHostMetaDataNameSpaces fetches namespaces of host metadata. 35 | func (c *Client) GetHostMetaDataNameSpaces(hostID string) ([]string, error) { 36 | data, err := requestGet[struct { 37 | MetaDatas []struct { 38 | NameSpace string `json:"namespace"` 39 | } `json:"metadata"` 40 | }](c, fmt.Sprintf("/api/v0/hosts/%s/metadata", hostID)) 41 | if err != nil { 42 | return nil, err 43 | } 44 | namespaces := make([]string, len(data.MetaDatas)) 45 | for i, metadata := range data.MetaDatas { 46 | namespaces[i] = metadata.NameSpace 47 | } 48 | return namespaces, nil 49 | } 50 | 51 | // PutHostMetaData puts a host metadata. 52 | func (c *Client) PutHostMetaData(hostID, namespace string, metadata HostMetaData) error { 53 | path := fmt.Sprintf("/api/v0/hosts/%s/metadata/%s", hostID, namespace) 54 | _, err := requestPut[any](c, path, metadata) 55 | return err 56 | } 57 | 58 | // DeleteHostMetaData deletes a host metadata. 59 | func (c *Client) DeleteHostMetaData(hostID, namespace string) error { 60 | path := fmt.Sprintf("/api/v0/hosts/%s/metadata/%s", hostID, namespace) 61 | _, err := requestDelete[any](c, path) 62 | return err 63 | } 64 | -------------------------------------------------------------------------------- /host_metadata_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestGetHostMetaData(t *testing.T) { 14 | var ( 15 | hostID = "9rxGOHfVF8F" 16 | namespace = "testing" 17 | lastModified = time.Date(2018, 3, 6, 3, 0, 0, 0, time.UTC) 18 | ) 19 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 20 | u := fmt.Sprintf("/api/v0/hosts/%s/metadata/%s", hostID, namespace) 21 | if req.URL.Path != u { 22 | t.Errorf("request URL should be %v but %v:", u, req.URL.Path) 23 | } 24 | 25 | if req.Method != "GET" { 26 | t.Error("request method should be GET but: ", req.Method) 27 | } 28 | 29 | respJSON := `{"type":12345,"region":"jp","env":"staging","instance_type":"c4.xlarge"}` 30 | res.Header()["Content-Type"] = []string{"application/json"} 31 | res.Header()["Last-Modified"] = []string{lastModified.Format(http.TimeFormat)} 32 | fmt.Fprint(res, respJSON) 33 | })) 34 | defer ts.Close() 35 | 36 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 37 | metadataResp, err := client.GetHostMetaData(hostID, namespace) 38 | if err != nil { 39 | t.Error("err should be nil but: ", err) 40 | } 41 | 42 | metadata := metadataResp.HostMetaData 43 | if metadata.(map[string]interface{})["type"].(float64) != 12345 { 44 | t.Errorf("got: %v, want: %v", metadata.(map[string]interface{})["type"], 12345) 45 | } 46 | if metadata.(map[string]interface{})["region"] != "jp" { 47 | t.Errorf("got: %v, want: %v", metadata.(map[string]interface{})["region"], "jp") 48 | } 49 | if metadata.(map[string]interface{})["env"] != "staging" { 50 | t.Errorf("got: %v, want: %v", metadata.(map[string]interface{})["env"], "staging") 51 | } 52 | if metadata.(map[string]interface{})["instance_type"] != "c4.xlarge" { 53 | t.Errorf("got: %v, want: %v", metadata.(map[string]interface{})["instance_type"], "c4.xlarge") 54 | } 55 | if !metadataResp.LastModified.Equal(lastModified) { 56 | t.Errorf("got: %v, want: %v", metadataResp.LastModified, lastModified) 57 | } 58 | } 59 | 60 | func TestGetHostMetaDataNameSpaces(t *testing.T) { 61 | var ( 62 | hostID = "9rxGOHfVF8F" 63 | ) 64 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 65 | u := fmt.Sprintf("/api/v0/hosts/%s/metadata", hostID) 66 | if req.URL.Path != u { 67 | t.Errorf("request URL should be %v but %v:", u, req.URL.Path) 68 | } 69 | 70 | if req.Method != "GET" { 71 | t.Error("request method should be GET but: ", req.Method) 72 | } 73 | 74 | respJSON := `{"metadata":[{"namespace":"testing1"}, {"namespace":"testing2"}]}` 75 | res.Header()["Content-Type"] = []string{"application/json"} 76 | fmt.Fprint(res, respJSON) 77 | })) 78 | defer ts.Close() 79 | 80 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 81 | namespaces, err := client.GetHostMetaDataNameSpaces(hostID) 82 | if err != nil { 83 | t.Error("err should be nil but: ", err) 84 | } 85 | 86 | if !reflect.DeepEqual(namespaces, []string{"testing1", "testing2"}) { 87 | t.Errorf("got: %v, want: %v", namespaces, []string{"testing1", "testing2"}) 88 | } 89 | } 90 | 91 | func TestPutHostMetaData(t *testing.T) { 92 | var ( 93 | hostID = "9rxGOHfVF8F" 94 | namespace = "testing" 95 | ) 96 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 97 | u := fmt.Sprintf("/api/v0/hosts/%s/metadata/%s", hostID, namespace) 98 | if req.URL.Path != u { 99 | t.Errorf("request URL should be %v but %v:", u, req.URL.Path) 100 | } 101 | 102 | if req.Method != "PUT" { 103 | t.Error("request method should be PUT but: ", req.Method) 104 | } 105 | 106 | body, _ := io.ReadAll(req.Body) 107 | reqJSON := `{"env":"staging","instance_type":"c4.xlarge","region":"jp","type":12345}` + "\n" 108 | if string(body) != reqJSON { 109 | t.Errorf("request body should be %v but %v", reqJSON, string(body)) 110 | } 111 | 112 | res.Header()["Content-Type"] = []string{"application/json"} 113 | fmt.Fprint(res, `{"success":true}`) 114 | })) 115 | defer ts.Close() 116 | 117 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 118 | metadata := map[string]interface{}{ 119 | "type": 12345, 120 | "region": "jp", 121 | "env": "staging", 122 | "instance_type": "c4.xlarge", 123 | } 124 | err := client.PutHostMetaData(hostID, namespace, &metadata) 125 | if err != nil { 126 | t.Error("err should be nil but: ", err) 127 | } 128 | } 129 | 130 | func TestDeleteHostMetaData(t *testing.T) { 131 | var ( 132 | hostID = "9rxGOHfVF8F" 133 | namespace = "testing" 134 | ) 135 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 136 | u := fmt.Sprintf("/api/v0/hosts/%s/metadata/%s", hostID, namespace) 137 | if req.URL.Path != u { 138 | t.Errorf("request URL should be %v but %v:", u, req.URL.Path) 139 | } 140 | 141 | if req.Method != "DELETE" { 142 | t.Error("request method should be DELETE but: ", req.Method) 143 | } 144 | 145 | res.Header()["Content-Type"] = []string{"application/json"} 146 | fmt.Fprint(res, `{"success":true}`) 147 | })) 148 | defer ts.Close() 149 | 150 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 151 | err := client.DeleteHostMetaData(hostID, namespace) 152 | if err != nil { 153 | t.Error("err should be nil but: ", err) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /hosts.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // Host host information 11 | type Host struct { 12 | ID string `json:"id"` 13 | Name string `json:"name"` 14 | DisplayName string `json:"displayName,omitempty"` 15 | CustomIdentifier string `json:"customIdentifier,omitempty"` 16 | Size string `json:"size"` 17 | Status string `json:"status"` 18 | Memo string `json:"memo"` 19 | Roles Roles `json:"roles"` 20 | IsRetired bool `json:"isRetired"` 21 | CreatedAt int32 `json:"createdAt"` 22 | Meta HostMeta `json:"meta"` 23 | Interfaces []Interface `json:"interfaces"` 24 | } 25 | 26 | // Roles host role maps 27 | type Roles map[string][]string 28 | 29 | // HostMeta host meta informations 30 | type HostMeta struct { 31 | AgentRevision string `json:"agent-revision,omitempty"` 32 | AgentVersion string `json:"agent-version,omitempty"` 33 | AgentName string `json:"agent-name,omitempty"` 34 | BlockDevice BlockDevice `json:"block_device,omitempty"` 35 | CPU CPU `json:"cpu,omitempty"` 36 | Filesystem FileSystem `json:"filesystem,omitempty"` 37 | Kernel Kernel `json:"kernel,omitempty"` 38 | Memory Memory `json:"memory,omitempty"` 39 | Cloud *Cloud `json:"cloud,omitempty"` 40 | } 41 | 42 | // BlockDevice blockdevice 43 | type BlockDevice map[string]map[string]interface{} 44 | 45 | // CPU cpu 46 | type CPU []map[string]interface{} 47 | 48 | // FileSystem filesystem 49 | type FileSystem map[string]interface{} 50 | 51 | // Kernel kernel 52 | type Kernel map[string]string 53 | 54 | // Memory memory 55 | type Memory map[string]string 56 | 57 | // Cloud cloud 58 | type Cloud struct { 59 | Provider string `json:"provider,omitempty"` 60 | MetaData interface{} `json:"metadata,omitempty"` 61 | } 62 | 63 | // Interface network interface 64 | type Interface struct { 65 | Name string `json:"name,omitempty"` 66 | IPAddress string `json:"ipAddress,omitempty"` 67 | IPv4Addresses []string `json:"ipv4Addresses,omitempty"` 68 | IPv6Addresses []string `json:"ipv6Addresses,omitempty"` 69 | MacAddress string `json:"macAddress,omitempty"` 70 | } 71 | 72 | // FindHostsParam parameters for FindHosts 73 | type FindHostsParam struct { 74 | Service string 75 | Roles []string 76 | Name string 77 | Statuses []string 78 | CustomIdentifier string 79 | } 80 | 81 | // CreateHostParam parameters for CreateHost 82 | type CreateHostParam struct { 83 | Name string `json:"name"` 84 | DisplayName string `json:"displayName,omitempty"` 85 | Memo string `json:"memo,omitempty"` 86 | Meta HostMeta `json:"meta"` 87 | Interfaces []Interface `json:"interfaces"` 88 | RoleFullnames []string `json:"roleFullnames"` 89 | Checks []CheckConfig `json:"checks"` 90 | CustomIdentifier string `json:"customIdentifier,omitempty"` 91 | } 92 | 93 | // CheckConfig is check plugin name and memo 94 | type CheckConfig struct { 95 | Name string `json:"name,omitempty"` 96 | Memo string `json:"memo,omitempty"` 97 | } 98 | 99 | // UpdateHostParam parameters for UpdateHost 100 | type UpdateHostParam CreateHostParam 101 | 102 | // MonitoredStatus monitored status 103 | type MonitoredStatus struct { 104 | MonitorID string `json:"monitorId"` 105 | Status string `json:"status"` 106 | Detail MonitoredStatusDetail `json:"detail,omitempty"` 107 | } 108 | 109 | // MonitoredStatusDetail monitored status detail 110 | type MonitoredStatusDetail struct { 111 | Type string `json:"type"` 112 | Message string `json:"message,omitempty"` 113 | Memo string `json:"memo,omitempty"` 114 | } 115 | 116 | // FindHostByCustomIdentifierParam parameters for FindHostByCustomIdentifier 117 | type FindHostByCustomIdentifierParam struct { 118 | CaseInsensitive bool 119 | } 120 | 121 | const ( 122 | // HostStatusWorking represents "working" status 123 | HostStatusWorking = "working" 124 | // HostStatusStandby represents "standby" status 125 | HostStatusStandby = "standby" 126 | // HostStatusMaintenance represents "maintenance" status 127 | HostStatusMaintenance = "maintenance" 128 | // HostStatusPoweroff represents "poeroff" status 129 | HostStatusPoweroff = "poweroff" 130 | ) 131 | 132 | // GetRoleFullnames gets role-full-names 133 | func (h *Host) GetRoleFullnames() []string { 134 | if len(h.Roles) < 1 { 135 | return nil 136 | } 137 | 138 | var fullnames []string 139 | for service, roles := range h.Roles { 140 | for _, role := range roles { 141 | fullname := strings.Join([]string{service, role}, ":") 142 | fullnames = append(fullnames, fullname) 143 | } 144 | } 145 | 146 | return fullnames 147 | } 148 | 149 | // DateFromCreatedAt returns time.Time 150 | func (h *Host) DateFromCreatedAt() time.Time { 151 | return time.Unix(int64(h.CreatedAt), 0) 152 | } 153 | 154 | // IPAddresses returns ipaddresses 155 | func (h *Host) IPAddresses() map[string]string { 156 | if len(h.Interfaces) < 1 { 157 | return nil 158 | } 159 | 160 | ipAddresses := make(map[string]string, 0) 161 | for _, iface := range h.Interfaces { 162 | ipAddresses[iface.Name] = iface.IPAddress 163 | } 164 | return ipAddresses 165 | } 166 | 167 | // FindHost finds the host. 168 | func (c *Client) FindHost(hostID string) (*Host, error) { 169 | data, err := requestGet[struct { 170 | Host *Host `json:"host"` 171 | }](c, fmt.Sprintf("/api/v0/hosts/%s", hostID)) 172 | if err != nil { 173 | return nil, err 174 | } 175 | return data.Host, nil 176 | } 177 | 178 | // FindHosts finds hosts. 179 | func (c *Client) FindHosts(param *FindHostsParam) ([]*Host, error) { 180 | params := url.Values{} 181 | if param.Service != "" { 182 | params.Set("service", param.Service) 183 | } 184 | for _, role := range param.Roles { 185 | params.Add("role", role) 186 | } 187 | if param.Name != "" { 188 | params.Set("name", param.Name) 189 | } 190 | for _, status := range param.Statuses { 191 | params.Add("status", status) 192 | } 193 | if param.CustomIdentifier != "" { 194 | params.Set("customIdentifier", param.CustomIdentifier) 195 | } 196 | 197 | data, err := requestGetWithParams[struct { 198 | Hosts []*Host `json:"hosts"` 199 | }](c, "/api/v0/hosts", params) 200 | if err != nil { 201 | return nil, err 202 | } 203 | return data.Hosts, nil 204 | } 205 | 206 | // FindHostByCustomIdentifier finds a host by the custom identifier. 207 | func (c *Client) FindHostByCustomIdentifier(customIdentifier string, param *FindHostByCustomIdentifierParam) (*Host, error) { 208 | path := "/api/v0/hosts-by-custom-identifier/" + url.PathEscape(customIdentifier) 209 | params := url.Values{} 210 | if param.CaseInsensitive { 211 | params.Set("caseInsensitive", "true") 212 | } 213 | data, err := requestGetWithParams[struct { 214 | Host *Host `json:"host"` 215 | }](c, path, params) 216 | if err != nil { 217 | return nil, err 218 | } 219 | return data.Host, nil 220 | } 221 | 222 | // CreateHost creates a host. 223 | func (c *Client) CreateHost(param *CreateHostParam) (string, error) { 224 | data, err := requestPost[struct { 225 | ID string `json:"id"` 226 | }](c, "/api/v0/hosts", param) 227 | if err != nil { 228 | return "", err 229 | } 230 | return data.ID, nil 231 | } 232 | 233 | // UpdateHost updates a host. 234 | func (c *Client) UpdateHost(hostID string, param *UpdateHostParam) (string, error) { 235 | path := fmt.Sprintf("/api/v0/hosts/%s", hostID) 236 | data, err := requestPut[struct { 237 | ID string `json:"id"` 238 | }](c, path, param) 239 | if err != nil { 240 | return "", err 241 | } 242 | return data.ID, nil 243 | } 244 | 245 | // UpdateHostStatus updates a host status. 246 | func (c *Client) UpdateHostStatus(hostID string, status string) error { 247 | path := fmt.Sprintf("/api/v0/hosts/%s/status", hostID) 248 | _, err := requestPost[any](c, path, map[string]string{"status": status}) 249 | return err 250 | } 251 | 252 | // BulkUpdateHostStatuses updates status of the hosts. 253 | func (c *Client) BulkUpdateHostStatuses(ids []string, status string) error { 254 | _, err := requestPost[any](c, "/api/v0/hosts/bulk-update-statuses", 255 | map[string]any{"ids": ids, "status": status}) 256 | return err 257 | } 258 | 259 | // UpdateHostRoleFullnames updates host roles. 260 | func (c *Client) UpdateHostRoleFullnames(hostID string, roleFullnames []string) error { 261 | path := fmt.Sprintf("/api/v0/hosts/%s/role-fullnames", hostID) 262 | _, err := requestPut[any](c, path, map[string][]string{"roleFullnames": roleFullnames}) 263 | return err 264 | } 265 | 266 | // RetireHost retires the host. 267 | func (c *Client) RetireHost(hostID string) error { 268 | path := fmt.Sprintf("/api/v0/hosts/%s/retire", hostID) 269 | _, err := requestPost[any](c, path, nil) 270 | return err 271 | } 272 | 273 | // BulkRetireHosts retires the hosts. 274 | func (c *Client) BulkRetireHosts(ids []string) error { 275 | _, err := requestPost[any](c, "/api/v0/hosts/bulk-retire", map[string][]string{"ids": ids}) 276 | return err 277 | } 278 | 279 | // ListHostMetricNames lists metric names of a host. 280 | func (c *Client) ListHostMetricNames(hostID string) ([]string, error) { 281 | data, err := requestGet[struct { 282 | Names []string `json:"names"` 283 | }](c, fmt.Sprintf("/api/v0/hosts/%s/metric-names", hostID)) 284 | if err != nil { 285 | return nil, err 286 | } 287 | return data.Names, nil 288 | } 289 | 290 | // ListMonitoredStatues lists monitored statues of a host. 291 | func (c *Client) ListMonitoredStatues(hostID string) ([]MonitoredStatus, error) { 292 | data, err := requestGet[struct { 293 | MonitoredStatuses []MonitoredStatus `json:"monitoredStatuses"` 294 | }](c, fmt.Sprintf("/api/v0/hosts/%s/monitored-statuses", hostID)) 295 | if err != nil { 296 | return nil, err 297 | } 298 | return data.MonitoredStatuses, nil 299 | } 300 | -------------------------------------------------------------------------------- /invitations.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | // Invitation information 4 | type Invitation struct { 5 | Email string `json:"email,omitempty"` 6 | Authority string `json:"authority,omitempty"` 7 | ExpiresAt int64 `json:"expiresAt,omitempty"` 8 | } 9 | 10 | // FindInvitations finds invitations. 11 | func (c *Client) FindInvitations() ([]*Invitation, error) { 12 | data, err := requestGet[struct { 13 | Invitations []*Invitation `json:"invitations"` 14 | }](c, "/api/v0/invitations") 15 | if err != nil { 16 | return nil, err 17 | } 18 | return data.Invitations, nil 19 | } 20 | 21 | // CreateInvitation creates a invitation. 22 | func (c *Client) CreateInvitation(param *Invitation) (*Invitation, error) { 23 | return requestPost[Invitation](c, "/api/v0/invitations", param) 24 | } 25 | -------------------------------------------------------------------------------- /invitations_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/kylelemons/godebug/pretty" 12 | ) 13 | 14 | func TestFindInvitation(t *testing.T) { 15 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 16 | if req.URL.Path != "/api/v0/invitations" { 17 | t.Error("request URL should be but: ", req.URL.Path) 18 | } 19 | if req.Method != "GET" { 20 | t.Error("request method should be GET but: ", req.Method) 21 | } 22 | 23 | respJSON, _ := json.Marshal(map[string][]map[string]interface{}{ 24 | "invitations": { 25 | { 26 | "email": "test@example.com", 27 | "authority": "viewer", 28 | "expiresAt": 1560000000, 29 | }, 30 | }, 31 | }) 32 | res.Header()["Content-Type"] = []string{"application/json"} 33 | fmt.Fprint(res, string(respJSON)) 34 | })) 35 | defer ts.Close() 36 | 37 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 38 | invitations, err := client.FindInvitations() 39 | 40 | if err != nil { 41 | t.Error("err should be nil but: ", err) 42 | } 43 | 44 | if invitations[0].Email != "test@example.com" { 45 | t.Error("request sends json including email but: ", invitations[0].Email) 46 | } 47 | 48 | if invitations[0].Authority != "viewer" { 49 | t.Error("request sends json including authority but: ", invitations[0].Authority) 50 | } 51 | 52 | if invitations[0].ExpiresAt != 1560000000 { 53 | t.Error("request sends json including joinedAt but: ", invitations[0].ExpiresAt) 54 | } 55 | } 56 | 57 | func TestCreateInvitation(t *testing.T) { 58 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 59 | if req.URL.Path != "/api/v0/invitations" { 60 | t.Error("request URL should be but: ", req.URL.Path) 61 | } 62 | if req.Method != http.MethodPost { 63 | t.Error("request method should be POST but: ", req.Method) 64 | } 65 | 66 | body, _ := io.ReadAll(req.Body) 67 | 68 | var invitation Invitation 69 | if err := json.Unmarshal(body, &invitation); err != nil { 70 | t.Fatal("request body should be decoded as json", string(body)) 71 | } 72 | 73 | respJSON, _ := json.Marshal(map[string]interface{}{ 74 | "email": "test@example.com", 75 | "authority": "viewer", 76 | "expiresAt": 1560000000, 77 | }) 78 | res.Header()["Content-Type"] = []string{"application/json"} 79 | fmt.Fprint(res, string(respJSON)) 80 | })) 81 | defer ts.Close() 82 | 83 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 84 | 85 | param := &Invitation{ 86 | Email: "test@example.com", 87 | Authority: "viewer", 88 | } 89 | 90 | got, err := client.CreateInvitation(param) 91 | if err != nil { 92 | t.Error("err should be nil but: ", err) 93 | } 94 | 95 | want := &Invitation{ 96 | Email: "test@example.com", 97 | Authority: "viewer", 98 | ExpiresAt: 1560000000, 99 | } 100 | 101 | if diff := pretty.Compare(got, want); diff != "" { 102 | t.Errorf("fail to get correct data: diff (-got +want)\n%s", diff) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /mackerel.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "log" 8 | "net/http" 9 | "net/http/httputil" 10 | "net/url" 11 | "time" 12 | ) 13 | 14 | const ( 15 | defaultBaseURL = "https://api.mackerelio.com/" 16 | defaultUserAgent = "mackerel-client-go" 17 | apiRequestTimeout = 30 * time.Second 18 | ) 19 | 20 | // PrioritizedLogger is the interface that groups prioritized logging methods. 21 | type PrioritizedLogger interface { 22 | Tracef(format string, v ...interface{}) 23 | Debugf(format string, v ...interface{}) 24 | Infof(format string, v ...interface{}) 25 | Warningf(format string, v ...interface{}) 26 | Errorf(format string, v ...interface{}) 27 | } 28 | 29 | // Client api client for mackerel 30 | type Client struct { 31 | BaseURL *url.URL 32 | APIKey string 33 | Verbose bool 34 | UserAgent string 35 | AdditionalHeaders http.Header 36 | HTTPClient *http.Client 37 | 38 | // Client will send logging events to both Logger and PrioritizedLogger. 39 | // When neither Logger or PrioritizedLogger is set, the log package's standard logger will be used. 40 | Logger *log.Logger 41 | PrioritizedLogger PrioritizedLogger 42 | } 43 | 44 | // NewClient returns new mackerel.Client 45 | func NewClient(apikey string) *Client { 46 | c, _ := NewClientWithOptions(apikey, defaultBaseURL, false) 47 | return c 48 | } 49 | 50 | // NewClientWithOptions returns new mackerel.Client 51 | func NewClientWithOptions(apikey string, rawurl string, verbose bool) (*Client, error) { 52 | u, err := url.Parse(rawurl) 53 | if err != nil { 54 | return nil, err 55 | } 56 | client := &http.Client{} 57 | client.Timeout = apiRequestTimeout 58 | return &Client{ 59 | BaseURL: u, 60 | APIKey: apikey, 61 | Verbose: verbose, 62 | UserAgent: defaultUserAgent, 63 | AdditionalHeaders: http.Header{}, 64 | HTTPClient: client, 65 | }, nil 66 | } 67 | 68 | func (c *Client) urlFor(path string, params url.Values) *url.URL { 69 | newURL, err := url.Parse(c.BaseURL.String()) 70 | if err != nil { 71 | panic("invalid base url") 72 | } 73 | newURL.Path = path 74 | newURL.RawQuery = params.Encode() 75 | return newURL 76 | } 77 | 78 | func (c *Client) buildReq(req *http.Request) *http.Request { 79 | for header, values := range c.AdditionalHeaders { 80 | for _, v := range values { 81 | req.Header.Add(header, v) 82 | } 83 | } 84 | req.Header.Set("X-Api-Key", c.APIKey) 85 | req.Header.Set("User-Agent", c.UserAgent) 86 | return req 87 | } 88 | 89 | func (c *Client) tracef(format string, v ...interface{}) { 90 | if c.PrioritizedLogger != nil { 91 | c.PrioritizedLogger.Tracef(format, v...) 92 | } 93 | if c.Logger != nil { 94 | c.Logger.Printf(format, v...) 95 | } 96 | if c.PrioritizedLogger == nil && c.Logger == nil { 97 | log.Printf(format, v...) 98 | } 99 | } 100 | 101 | // Request request to mackerel and receive response 102 | func (c *Client) Request(req *http.Request) (resp *http.Response, err error) { 103 | req = c.buildReq(req) 104 | 105 | if c.Verbose { 106 | dump, err := httputil.DumpRequest(req, true) 107 | if err == nil { 108 | c.tracef("%s", dump) 109 | } 110 | } 111 | 112 | resp, err = c.HTTPClient.Do(req) 113 | if err != nil { 114 | return nil, err 115 | } 116 | if c.Verbose { 117 | dump, err := httputil.DumpResponse(resp, true) 118 | if err == nil { 119 | c.tracef("%s", dump) 120 | } 121 | } 122 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 123 | message, err := extractErrorMessage(resp.Body) 124 | defer resp.Body.Close() 125 | if err != nil { 126 | return nil, &APIError{StatusCode: resp.StatusCode, Message: resp.Status} 127 | } 128 | return nil, &APIError{StatusCode: resp.StatusCode, Message: message} 129 | } 130 | return resp, nil 131 | } 132 | 133 | func requestGet[T any](client *Client, path string) (*T, error) { 134 | return requestNoBody[T](client, http.MethodGet, path, nil) 135 | } 136 | 137 | func requestGetWithParams[T any](client *Client, path string, params url.Values) (*T, error) { 138 | return requestNoBody[T](client, http.MethodGet, path, params) 139 | } 140 | 141 | func requestGetAndReturnHeader[T any](client *Client, path string) (*T, http.Header, error) { 142 | return requestInternal[T](client, http.MethodGet, path, nil, nil) 143 | } 144 | 145 | func requestPost[T any](client *Client, path string, payload any) (*T, error) { 146 | return requestJSON[T](client, http.MethodPost, path, payload) 147 | } 148 | 149 | func requestPut[T any](client *Client, path string, payload any) (*T, error) { 150 | return requestJSON[T](client, http.MethodPut, path, payload) 151 | } 152 | 153 | func requestDelete[T any](client *Client, path string) (*T, error) { 154 | return requestNoBody[T](client, http.MethodDelete, path, nil) 155 | } 156 | 157 | func requestJSON[T any](client *Client, method, path string, payload any) (*T, error) { 158 | var body bytes.Buffer 159 | err := json.NewEncoder(&body).Encode(payload) 160 | if err != nil { 161 | return nil, err 162 | } 163 | data, _, err := requestInternal[T](client, method, path, nil, &body) 164 | return data, err 165 | } 166 | 167 | func requestNoBody[T any](client *Client, method, path string, params url.Values) (*T, error) { 168 | data, _, err := requestInternal[T](client, method, path, params, nil) 169 | return data, err 170 | } 171 | 172 | func requestInternal[T any](client *Client, method, path string, params url.Values, body io.Reader) (*T, http.Header, error) { 173 | req, err := http.NewRequest(method, client.urlFor(path, params).String(), body) 174 | if err != nil { 175 | return nil, nil, err 176 | } 177 | if body != nil || method != http.MethodGet { 178 | req.Header.Add("Content-Type", "application/json") 179 | } 180 | 181 | resp, err := client.Request(req) 182 | if err != nil { 183 | return nil, nil, err 184 | } 185 | defer func() { 186 | io.Copy(io.Discard, resp.Body) // nolint 187 | resp.Body.Close() 188 | }() 189 | 190 | var data T 191 | err = json.NewDecoder(resp.Body).Decode(&data) 192 | if err != nil { 193 | return nil, nil, err 194 | } 195 | return &data, resp.Header, nil 196 | } 197 | 198 | func (c *Client) compatRequestJSON(method string, path string, payload interface{}) (*http.Response, error) { 199 | var body bytes.Buffer 200 | err := json.NewEncoder(&body).Encode(payload) 201 | if err != nil { 202 | return nil, err 203 | } 204 | 205 | req, err := http.NewRequest(method, c.urlFor(path, url.Values{}).String(), &body) 206 | if err != nil { 207 | return nil, err 208 | } 209 | req.Header.Add("Content-Type", "application/json") 210 | return c.Request(req) 211 | } 212 | 213 | // ToPtr returns a pointer to the given value of any type. 214 | func ToPtr[T any](v T) *T { 215 | return &v 216 | } 217 | 218 | // Deprecated: use other prefered method. 219 | func (c *Client) PostJSON(path string, payload interface{}) (*http.Response, error) { 220 | return c.compatRequestJSON(http.MethodPost, path, payload) 221 | } 222 | 223 | // Deprecated: use other prefered method. 224 | func (c *Client) PutJSON(path string, payload interface{}) (*http.Response, error) { 225 | return c.compatRequestJSON(http.MethodPut, path, payload) 226 | } 227 | -------------------------------------------------------------------------------- /mackerel_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "os" 12 | "strings" 13 | "testing" 14 | ) 15 | 16 | func TestRequest(t *testing.T) { 17 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 18 | if req.Header.Get("X-Api-Key") != "dummy-key" { 19 | t.Error("X-Api-Key header should contains passed key") 20 | } 21 | 22 | if h := req.Header.Get("User-Agent"); h != defaultUserAgent { 23 | t.Errorf("User-Agent should be '%s' but %s", defaultUserAgent, h) 24 | } 25 | })) 26 | defer ts.Close() 27 | 28 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 29 | 30 | req, _ := http.NewRequest("GET", client.urlFor("/", nil).String(), nil) 31 | _, err := client.Request(req) 32 | if err != nil { 33 | t.Errorf("request is error %v", err) 34 | } 35 | } 36 | 37 | func Test_requestInternal(t *testing.T) { 38 | tests := []struct { 39 | method string 40 | body io.Reader 41 | hasContentTypeHeader bool 42 | }{ 43 | {http.MethodGet, nil, false}, 44 | {http.MethodPost, nil, true}, 45 | {http.MethodPut, nil, true}, 46 | {http.MethodDelete, nil, true}, 47 | {http.MethodGet, strings.NewReader("some"), true}, 48 | {http.MethodPost, strings.NewReader("some"), true}, 49 | {http.MethodPut, strings.NewReader("some"), true}, 50 | {http.MethodDelete, strings.NewReader("some"), true}, 51 | } 52 | for _, test := range tests { 53 | t.Run(fmt.Sprintf("%s with %v body", test.method, test.body), func(tt *testing.T) { 54 | // Test server that make requests consistent with Mackerel behavior 55 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 56 | if !test.hasContentTypeHeader && req.Header.Get("Content-Type") == "application/json" { 57 | t.Error("Content-Type header should not have application/json") 58 | } 59 | if test.hasContentTypeHeader && req.Header.Get("Content-Type") != "application/json" { 60 | t.Error("Content-Type header should have application/json") 61 | } 62 | res.Write([]byte(`{"success": true}`)) // nolint 63 | })) 64 | defer ts.Close() 65 | 66 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 67 | res, _, err := requestInternal[struct { 68 | Success bool `json:"success"` 69 | }](client, test.method, "/", url.Values{}, test.body) 70 | if err != nil { 71 | t.Errorf("request is error %v", err) 72 | } 73 | if !res.Success { 74 | t.Errorf("response is invalid %v", res) 75 | } 76 | }) 77 | } 78 | } 79 | 80 | func TestUrlFor(t *testing.T) { 81 | client, _ := NewClientWithOptions("dummy-key", "https://example.com/with/ignored/path", false) 82 | expected := "https://example.com/some/super/endpoint" 83 | if url := client.urlFor("/some/super/endpoint", nil).String(); url != expected { 84 | t.Errorf("urlFor should be %q but %q", expected, url) 85 | } 86 | 87 | expected += "?test1=value1&test1=value2&test2=value2" 88 | params := url.Values{} 89 | params.Add("test1", "value1") 90 | params.Add("test1", "value2") 91 | params.Add("test2", "value2") 92 | if url := client.urlFor("/some/super/endpoint", params).String(); url != expected { 93 | t.Errorf("urlFor should be %q but %q", expected, url) 94 | } 95 | } 96 | 97 | func TestBuildReq(t *testing.T) { 98 | cl := NewClient("dummy-key") 99 | xVer := "1.0.1" 100 | xRev := "shasha" 101 | cl.AdditionalHeaders = http.Header{ 102 | "X-Agent-Version": []string{xVer}, 103 | "X-Revision": []string{xRev}, 104 | } 105 | cl.UserAgent = "mackerel-agent" 106 | req, _ := http.NewRequest("GET", cl.urlFor("/", nil).String(), nil) 107 | req = cl.buildReq(req) 108 | 109 | if req.Header.Get("X-Api-Key") != "dummy-key" { 110 | t.Error("X-Api-Key header should contains passed key") 111 | } 112 | if h := req.Header.Get("User-Agent"); h != cl.UserAgent { 113 | t.Errorf("User-Agent should be '%s' but %s", cl.UserAgent, h) 114 | } 115 | if h := req.Header.Get("X-Agent-Version"); h != xVer { 116 | t.Errorf("X-Agent-Version should be '%s' but %s", xVer, h) 117 | } 118 | if h := req.Header.Get("X-Revision"); h != xRev { 119 | t.Errorf("X-Revision should be '%s' but %s", xRev, h) 120 | } 121 | } 122 | 123 | func TestLogger(t *testing.T) { 124 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 125 | res.Write([]byte("OK")) //nolint 126 | })) 127 | defer ts.Close() 128 | 129 | client, _ := NewClientWithOptions("dummy-key", ts.URL, true) 130 | var buf bytes.Buffer 131 | client.Logger = log.New(&buf, "", 0) 132 | req, _ := http.NewRequest("GET", client.urlFor("/", nil).String(), nil) 133 | _, err := client.Request(req) 134 | if err != nil { 135 | t.Errorf("request is error %v", err) 136 | } 137 | s := strings.TrimSpace(buf.String()) 138 | if !strings.HasPrefix(s, "") || !strings.HasSuffix(s, "OK") { 139 | t.Errorf("verbose log should match /.*OK/; but %s", s) 140 | } 141 | } 142 | 143 | type fakeLogger struct { 144 | w io.Writer 145 | } 146 | 147 | func (p *fakeLogger) Tracef(format string, v ...interface{}) { 148 | fmt.Fprintf(p.w, format, v...) 149 | } 150 | func (p *fakeLogger) Debugf(format string, v ...interface{}) {} 151 | func (p *fakeLogger) Infof(format string, v ...interface{}) {} 152 | func (p *fakeLogger) Warningf(format string, v ...interface{}) {} 153 | func (p *fakeLogger) Errorf(format string, v ...interface{}) {} 154 | 155 | func TestPrivateTracef(t *testing.T) { 156 | var ( 157 | stdbuf bytes.Buffer 158 | logbuf bytes.Buffer 159 | pbuf bytes.Buffer 160 | ) 161 | log.SetOutput(&stdbuf) 162 | defer log.SetOutput(os.Stderr) 163 | oflags := log.Flags() 164 | defer log.SetFlags(oflags) 165 | log.SetFlags(0) 166 | 167 | const msg = "test\n" 168 | t.Run("Logger+PrioritizedLogger", func(t *testing.T) { 169 | var c Client 170 | c.Logger = log.New(&logbuf, "", 0) 171 | c.PrioritizedLogger = &fakeLogger{w: &pbuf} 172 | c.tracef(msg) 173 | if s := stdbuf.String(); s != "" { 174 | t.Errorf("tracef(%q): log.Printf(%q); want %q", msg, s, "") 175 | } 176 | if s := logbuf.String(); s != msg { 177 | t.Errorf("tracef(%q): Logger.Printf(%q); want %q", msg, s, msg) 178 | } 179 | if s := pbuf.String(); s != msg { 180 | t.Errorf("tracef(%q): PrioritizedLogger.Tracef(%q); want %q", msg, s, msg) 181 | } 182 | }) 183 | 184 | stdbuf.Reset() 185 | logbuf.Reset() 186 | pbuf.Reset() 187 | t.Run("Logger", func(t *testing.T) { 188 | var c Client 189 | c.Logger = log.New(&logbuf, "", 0) 190 | c.tracef(msg) 191 | if s := stdbuf.String(); s != "" { 192 | t.Errorf("tracef(%q): log.Printf(%q); want %q", msg, s, "") 193 | } 194 | if s := logbuf.String(); s != msg { 195 | t.Errorf("tracef(%q): Logger.Printf(%q); want %q", msg, s, msg) 196 | } 197 | if s := pbuf.String(); s != "" { 198 | t.Errorf("tracef(%q): PrioritizedLogger.Tracef(%q); want %q", msg, s, "") 199 | } 200 | }) 201 | 202 | stdbuf.Reset() 203 | logbuf.Reset() 204 | pbuf.Reset() 205 | t.Run("PrioritizedLogger", func(t *testing.T) { 206 | var c Client 207 | c.PrioritizedLogger = &fakeLogger{w: &pbuf} 208 | c.tracef(msg) 209 | if s := stdbuf.String(); s != "" { 210 | t.Errorf("tracef(%q): log.Printf(%q); want %q", msg, s, "") 211 | } 212 | if s := logbuf.String(); s != "" { 213 | t.Errorf("tracef(%q): Logger.Printf(%q); want %q", msg, s, "") 214 | } 215 | if s := pbuf.String(); s != msg { 216 | t.Errorf("tracef(%q): PrioritizedLogger.Tracef(%q); want %q", msg, s, msg) 217 | } 218 | }) 219 | 220 | stdbuf.Reset() 221 | logbuf.Reset() 222 | pbuf.Reset() 223 | t.Run("default", func(t *testing.T) { 224 | var c Client 225 | c.tracef(msg) 226 | if s := stdbuf.String(); s != msg { 227 | t.Errorf("tracef(%q): log.Printf(%q); want %q", msg, s, msg) 228 | } 229 | if s := logbuf.String(); s != "" { 230 | t.Errorf("tracef(%q): Logger.Printf(%q); want %q", msg, s, "") 231 | } 232 | if s := pbuf.String(); s != "" { 233 | t.Errorf("tracef(%q): PrioritizedLogger.Tracef(%q); want %q", msg, s, "") 234 | } 235 | }) 236 | } 237 | -------------------------------------------------------------------------------- /metrics.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | // MetricValue metric value 11 | type MetricValue struct { 12 | Name string `json:"name,omitempty"` 13 | Time int64 `json:"time,omitempty"` 14 | Value interface{} `json:"value,omitempty"` 15 | } 16 | 17 | // HostMetricValue host metric value 18 | type HostMetricValue struct { 19 | HostID string `json:"hostId,omitempty"` 20 | *MetricValue 21 | } 22 | 23 | // LatestMetricValues latest metric value 24 | type LatestMetricValues map[string]map[string]*MetricValue 25 | 26 | // PostHostMetricValues post host metrics 27 | func (c *Client) PostHostMetricValues(metricValues []*HostMetricValue) error { 28 | _, err := requestPost[any](c, "/api/v0/tsdb", metricValues) 29 | return err 30 | } 31 | 32 | // PostHostMetricValuesByHostID post host metrics 33 | func (c *Client) PostHostMetricValuesByHostID(hostID string, metricValues []*MetricValue) error { 34 | var hostMetricValues []*HostMetricValue 35 | for _, metricValue := range metricValues { 36 | hostMetricValues = append(hostMetricValues, &HostMetricValue{ 37 | HostID: hostID, 38 | MetricValue: metricValue, 39 | }) 40 | } 41 | return c.PostHostMetricValues(hostMetricValues) 42 | } 43 | 44 | // PostServiceMetricValues posts service metrics. 45 | func (c *Client) PostServiceMetricValues(serviceName string, metricValues []*MetricValue) error { 46 | path := fmt.Sprintf("/api/v0/services/%s/tsdb", serviceName) 47 | _, err := requestPost[any](c, path, metricValues) 48 | return err 49 | } 50 | 51 | // FetchLatestMetricValues fetches latest metrics. 52 | func (c *Client) FetchLatestMetricValues(hostIDs []string, metricNames []string) (LatestMetricValues, error) { 53 | params := url.Values{} 54 | for _, hostID := range hostIDs { 55 | params.Add("hostId", hostID) 56 | } 57 | for _, metricName := range metricNames { 58 | params.Add("name", metricName) 59 | } 60 | 61 | data, err := requestGetWithParams[struct { 62 | LatestMetricValues LatestMetricValues `json:"tsdbLatest"` 63 | }](c, "/api/v0/tsdb/latest", params) 64 | if err != nil { 65 | return nil, err 66 | } 67 | return data.LatestMetricValues, nil 68 | } 69 | 70 | // FetchHostMetricValues fetches the metric values for a host. 71 | func (c *Client) FetchHostMetricValues(hostID string, metricName string, from int64, to int64) ([]MetricValue, error) { 72 | return c.fetchMetricValues(hostID, "", metricName, from, to) 73 | } 74 | 75 | // FetchServiceMetricValues fetches the metric values for a service. 76 | func (c *Client) FetchServiceMetricValues(serviceName string, metricName string, from int64, to int64) ([]MetricValue, error) { 77 | return c.fetchMetricValues("", serviceName, metricName, from, to) 78 | } 79 | 80 | func (c *Client) fetchMetricValues(hostID string, serviceName string, metricName string, from int64, to int64) ([]MetricValue, error) { 81 | params := url.Values{} 82 | params.Add("name", metricName) 83 | params.Add("from", strconv.FormatInt(from, 10)) 84 | params.Add("to", strconv.FormatInt(to, 10)) 85 | 86 | path := "" 87 | if hostID != "" { 88 | path = fmt.Sprintf("/api/v0/hosts/%s/metrics", hostID) 89 | } else if serviceName != "" { 90 | path = fmt.Sprintf("/api/v0/services/%s/metrics", serviceName) 91 | } else { 92 | return nil, errors.New("specify either host or service") 93 | } 94 | 95 | data, err := requestGetWithParams[struct { 96 | MetricValues []MetricValue `json:"metrics"` 97 | }](c, path, params) 98 | if err != nil { 99 | return nil, err 100 | } 101 | return data.MetricValues, nil 102 | } 103 | -------------------------------------------------------------------------------- /metrics_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "reflect" 10 | "testing" 11 | ) 12 | 13 | func TestPostHostMetricValues(t *testing.T) { 14 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 15 | if req.URL.Path != "/api/v0/tsdb" { 16 | t.Error("request URL should be /api/v0/tsdb but: ", req.URL.Path) 17 | } 18 | 19 | if req.Method != "POST" { 20 | t.Error("request method should be POST but: ", req.Method) 21 | } 22 | 23 | body, _ := io.ReadAll(req.Body) 24 | 25 | var values []struct { 26 | HostID string `json:"hostId"` 27 | Name string `json:"name"` 28 | Time float64 `json:"time"` 29 | Value interface{} `json:"value"` 30 | } 31 | 32 | err := json.Unmarshal(body, &values) 33 | if err != nil { 34 | t.Fatal("request body should be decoded as json", string(body)) 35 | } 36 | 37 | if values[0].HostID != "9rxGOHfVF8F" { 38 | t.Error("request sends json including hostId but: ", values[0].HostID) 39 | } 40 | if values[0].Name != "custom.metric.mysql.connections" { 41 | t.Error("request sends json including name but: ", values[0].Name) 42 | } 43 | if values[0].Time != 123456789 { 44 | t.Error("request sends json including time but: ", values[0].Time) 45 | } 46 | if values[0].Value.(float64) != 100 { 47 | t.Error("request sends json including value but: ", values[0].Value) 48 | } 49 | 50 | respJSON, _ := json.Marshal(map[string]bool{ 51 | "success": true, 52 | }) 53 | 54 | res.Header()["Content-Type"] = []string{"application/json"} 55 | fmt.Fprint(res, string(respJSON)) 56 | })) 57 | defer ts.Close() 58 | 59 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 60 | err := client.PostHostMetricValues([]*HostMetricValue{ 61 | { 62 | HostID: "9rxGOHfVF8F", 63 | MetricValue: &MetricValue{ 64 | Name: "custom.metric.mysql.connections", 65 | Time: 123456789, 66 | Value: 100, 67 | }, 68 | }, 69 | }) 70 | 71 | if err != nil { 72 | t.Error("err should be nil but: ", err) 73 | } 74 | } 75 | 76 | func TestPostServiceMetricValues(t *testing.T) { 77 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 78 | if req.URL.Path != "/api/v0/services/My-Service/tsdb" { 79 | t.Error("request URL should be /api/v0/services/My-Service/tsdb but: ", req.URL.Path) 80 | } 81 | 82 | if req.Method != "POST" { 83 | t.Error("request method should be POST but: ", req.Method) 84 | } 85 | 86 | body, _ := io.ReadAll(req.Body) 87 | 88 | var values []struct { 89 | Name string `json:"name"` 90 | Time float64 `json:"time"` 91 | Value interface{} `json:"value"` 92 | } 93 | 94 | err := json.Unmarshal(body, &values) 95 | if err != nil { 96 | t.Fatal("request body should be decoded as json", string(body)) 97 | } 98 | 99 | if values[0].Name != "proxy.access_log.latency" { 100 | t.Error("request sends json including name but: ", values[0].Name) 101 | } 102 | if values[0].Time != 123456789 { 103 | t.Error("request sends json including time but: ", values[0].Time) 104 | } 105 | if values[0].Value.(float64) != 500 { 106 | t.Error("request sends json including value but: ", values[0].Value) 107 | } 108 | 109 | respJSON, _ := json.Marshal(map[string]bool{ 110 | "success": true, 111 | }) 112 | 113 | res.Header()["Content-Type"] = []string{"application/json"} 114 | fmt.Fprint(res, string(respJSON)) 115 | })) 116 | defer ts.Close() 117 | 118 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 119 | err := client.PostServiceMetricValues("My-Service", []*MetricValue{ 120 | { 121 | Name: "proxy.access_log.latency", 122 | Time: 123456789, 123 | Value: 500, 124 | }, 125 | }) 126 | 127 | if err != nil { 128 | t.Error("err should be nil but: ", err) 129 | } 130 | } 131 | 132 | func TestFetchLatestMetricValues(t *testing.T) { 133 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 134 | if req.URL.Path != "/api/v0/tsdb/latest" { 135 | t.Error("request URL should be /api/v0/tsdb/latest but: ", req.URL.Path) 136 | } 137 | 138 | if req.Method != "GET" { 139 | t.Error("request method should be GET but: ", req.Method) 140 | } 141 | 142 | query := req.URL.Query() 143 | if !reflect.DeepEqual(query["hostId"], []string{"123456ABCD", "654321ABCD"}) { 144 | t.Error("request query 'hostId' param should be ['123456ABCD', '654321ABCD'] but: ", query["hostId"]) 145 | } 146 | if !reflect.DeepEqual(query["name"], []string{"mysql.connections.Connections", "mysql.connections.Thread_created"}) { 147 | t.Error("request query 'name' param should be ['mysql.connections.Connections', 'mysql.connections.Thread_created'] but: ", query["name"]) 148 | } 149 | 150 | respJSON, _ := json.Marshal(map[string]map[string]map[string]*MetricValue{ 151 | "tsdbLatest": { 152 | "123456ABCD": { 153 | "mysql.connections.Connections": { 154 | Name: "mysql.connections.Connections", 155 | Time: 123456789, 156 | Value: 200, 157 | }, 158 | "mysql.connections.Thread_created": { 159 | Name: "mysql.connections.Thread_created", 160 | Time: 123456789, 161 | Value: 220, 162 | }, 163 | }, 164 | "654321ABCD": { 165 | "mysql.connections.Connections": { 166 | Name: "mysql.connections.Connections", 167 | Time: 123456789, 168 | Value: 300, 169 | }, 170 | "mysql.connections.Thread_created": { 171 | Name: "mysql.connections.Thread_created", 172 | Time: 123456789, 173 | Value: 310, 174 | }, 175 | }, 176 | }, 177 | }) 178 | 179 | res.Header()["Content-Type"] = []string{"application/json"} 180 | fmt.Fprint(res, string(respJSON)) 181 | })) 182 | defer ts.Close() 183 | 184 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 185 | hostIDs := []string{"123456ABCD", "654321ABCD"} 186 | metricNames := []string{"mysql.connections.Connections", "mysql.connections.Thread_created"} 187 | latestMetricValues, err := client.FetchLatestMetricValues(hostIDs, metricNames) 188 | 189 | if err != nil { 190 | t.Error("err should be nil but: ", err) 191 | } 192 | 193 | if latestMetricValues["123456ABCD"]["mysql.connections.Connections"].Value.(float64) != 200 { 194 | t.Error("123456ABCD host mysql.connections.Connections should be 200 but: ", latestMetricValues["123456ABCD"]["mysql.connections.Connections"].Value) 195 | } 196 | 197 | if latestMetricValues["654321ABCD"]["mysql.connections.Connections"].Value.(float64) != 300 { 198 | t.Error("654321ABCD host mysql.connections.Connections should be 300 but: ", latestMetricValues["654321ABCD"]["mysql.connections.Connections"].Value) 199 | } 200 | } 201 | 202 | func TestFetchHostMetricValues(t *testing.T) { 203 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 204 | if req.URL.Path != "/api/v0/hosts/123456ABCD/metrics" { 205 | t.Error("request URL should be /api/v0/hosts/123456ABCD/metrics but: ", req.URL.Path) 206 | } 207 | 208 | if req.Method != "GET" { 209 | t.Error("request method should be GET but: ", req.Method) 210 | } 211 | 212 | query := req.URL.Query() 213 | if !reflect.DeepEqual(query["name"], []string{"mysql.connections.Connections"}) { 214 | t.Error("request query 'name' param should be ['mysql.connections.Connections'] but: ", query["name"]) 215 | } 216 | 217 | respJSON, _ := json.Marshal(map[string][]MetricValue{ 218 | "metrics": { 219 | { 220 | Time: 1450000800, 221 | Value: 200, 222 | }, 223 | { 224 | Time: 1450000860, 225 | Value: 220, 226 | }, 227 | { 228 | Time: 1450000920, 229 | Value: 240, 230 | }, 231 | }, 232 | }) 233 | 234 | res.Header()["Content-Type"] = []string{"application/json"} 235 | fmt.Fprint(res, string(respJSON)) 236 | })) 237 | defer ts.Close() 238 | 239 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 240 | hostID := "123456ABCD" 241 | metricName := "mysql.connections.Connections" 242 | metricValues, err := client.FetchHostMetricValues(hostID, metricName, 1450000700, 1450001000) 243 | 244 | if err != nil { 245 | t.Error("err should be nil but: ", err) 246 | } 247 | 248 | if metricValues[0].Value.(float64) != 200 { 249 | t.Error("123456ABCD host mysql.connections.Connections should be 200 but: ", metricValues[0].Value) 250 | } 251 | 252 | if metricValues[1].Value.(float64) != 220 { 253 | t.Error("123456ABCD host mysql.connections.Connections should be 220 but: ", metricValues[1].Value) 254 | } 255 | 256 | if metricValues[2].Value.(float64) != 240 { 257 | t.Error("123456ABCD host mysql.connections.Connections should be 240 but: ", metricValues[2].Value) 258 | } 259 | } 260 | 261 | func TestFetchServiceMetricValues(t *testing.T) { 262 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 263 | if req.URL.Path != "/api/v0/services/123456ABCD/metrics" { 264 | t.Error("request URL should be /api/v0/services/123456ABCD/metrics but: ", req.URL.Path) 265 | } 266 | 267 | if req.Method != "GET" { 268 | t.Error("request method should be GET but: ", req.Method) 269 | } 270 | 271 | query := req.URL.Query() 272 | if !reflect.DeepEqual(query["name"], []string{"custom.access_latency.avg"}) { 273 | t.Error("request query 'name' param should be ['custom.access_latency.avg'] but: ", query["name"]) 274 | } 275 | 276 | respJSON, _ := json.Marshal(map[string][]MetricValue{ 277 | "metrics": { 278 | { 279 | Time: 1450000800, 280 | Value: 0.12, 281 | }, 282 | { 283 | Time: 1450000860, 284 | Value: 0.14, 285 | }, 286 | { 287 | Time: 1450000920, 288 | Value: 0.16, 289 | }, 290 | }, 291 | }) 292 | 293 | res.Header()["Content-Type"] = []string{"application/json"} 294 | fmt.Fprint(res, string(respJSON)) 295 | })) 296 | defer ts.Close() 297 | 298 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 299 | serviceName := "123456ABCD" 300 | metricName := "custom.access_latency.avg" 301 | metricValues, err := client.FetchServiceMetricValues(serviceName, metricName, 1450000700, 1450001000) 302 | 303 | if err != nil { 304 | t.Error("err should be nil but: ", err) 305 | } 306 | 307 | if metricValues[0].Value.(float64) != 0.12 { 308 | t.Error("123456ABCD host custom.access_latency.avg should be 0.12 but: ", metricValues[0].Value) 309 | } 310 | 311 | if metricValues[1].Value.(float64) != 0.14 { 312 | t.Error("123456ABCD host custom.access_latency.avg should be 0.14 but: ", metricValues[1].Value) 313 | } 314 | 315 | if metricValues[2].Value.(float64) != 0.16 { 316 | t.Error("123456ABCD host custom.access_latency.avg should be 0.16 but: ", metricValues[2].Value) 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /monitors.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | ) 9 | 10 | /* 11 | { 12 | "monitors": [ 13 | { 14 | "id": "2cSZzK3XfmG", 15 | "type": "connectivity", 16 | "isMute": false, 17 | "scopes": [], 18 | "excludeScopes": [], 19 | "alertStatusOnGone": "WARNING" 20 | }, 21 | { 22 | "id" : "2cSZzK3XfmG", 23 | "type": "host", 24 | "isMute": false, 25 | "name": "disk.aa-00.writes.delta", 26 | "duration": 3, 27 | "metric": "disk.aa-00.writes.delta", 28 | "operator": ">", 29 | "warning": 20000.0, 30 | "critical": 400000.0, 31 | "scopes": [ 32 | "SomeService" 33 | ], 34 | "excludeScopes": [ 35 | "SomeService: db-slave-backup" 36 | ], 37 | "maxCheckAttempts": 1, 38 | "notificationInterval": 60 39 | }, 40 | { 41 | "id" : "2cSZzK3XfmG", 42 | "type": "service", 43 | "isMute": false, 44 | "name": "SomeService - custom.access_num.4xx_count", 45 | "service": "SomeService", 46 | "duration": 1, 47 | "metric": "custom.access_num.4xx_count", 48 | "operator": ">", 49 | "warning": 50.0, 50 | "critical": 100.0, 51 | "maxCheckAttempts": 1, 52 | "missingDurationWarning": 360, 53 | "missingDurationCritical": 720 54 | }, 55 | { 56 | "id" : "2cSZzK3XfmG", 57 | "type": "external", 58 | "isMute": false, 59 | "name": "example.com", 60 | "method": "GET", 61 | "url": "http://www.example.com", 62 | "service": "SomeService", 63 | "maxCheckAttempts": 1, 64 | "responseTimeCritical": 10000, 65 | "responseTimeWarning": 5000, 66 | "responseTimeDuration": 5, 67 | "certificationExpirationCritical": 15, 68 | "certificationExpirationWarning": 30, 69 | "expectedStatusCode": 200, 70 | "requestBody": "Request Body", 71 | "containsString": "Example", 72 | "skipCertificateVerification": true, 73 | "followRedirect": true, 74 | "headers": [ 75 | { "name": "Cache-Control", "value": "no-cache"} 76 | ] 77 | }, 78 | { 79 | "id": "3CSsK3HKiHb", 80 | "type": "anomalyDetection", 81 | "isMute": false, 82 | "name": "My first anomaly detection", 83 | "trainingPeriodFrom": 1561429260, 84 | "scopes": [ 85 | "MyService: MyRole" 86 | ], 87 | "maxCheckAttempts": 3, 88 | "warningSensitivity": "insensitive" 89 | }, 90 | { 91 | "id": "57We5nNtpZA", 92 | "type": "query", 93 | "isMute": false, 94 | "name": "LabeldMetric - custom.access_counter", 95 | "query": "custom.access_counter", 96 | "operator": ">", 97 | "warning": 30.0, 98 | "critical": 300.0, 99 | "legend":"" 100 | } 101 | ] 102 | } 103 | */ 104 | // Monitor represents interface to which each monitor type must confirm to. 105 | type Monitor interface { 106 | MonitorType() string 107 | MonitorID() string 108 | MonitorName() string 109 | 110 | isMonitor() 111 | } 112 | 113 | const ( 114 | monitorTypeConnectivity = "connectivity" 115 | monitorTypeHostMetric = "host" 116 | monitorTypeServiceMetric = "service" 117 | monitorTypeExternalHTTP = "external" 118 | monitorTypeExpression = "expression" 119 | monitorTypeAnomalyDetection = "anomalyDetection" 120 | monitorTypeQuery = "query" 121 | ) 122 | 123 | // Ensure each monitor type conforms to the Monitor interface. 124 | var ( 125 | _ Monitor = (*MonitorConnectivity)(nil) 126 | _ Monitor = (*MonitorHostMetric)(nil) 127 | _ Monitor = (*MonitorServiceMetric)(nil) 128 | _ Monitor = (*MonitorExternalHTTP)(nil) 129 | _ Monitor = (*MonitorExpression)(nil) 130 | _ Monitor = (*MonitorAnomalyDetection)(nil) 131 | _ Monitor = (*MonitorQuery)(nil) 132 | ) 133 | 134 | // Ensure only monitor types defined in this package can be assigned to the 135 | // Monitor interface. 136 | func (m *MonitorConnectivity) isMonitor() {} 137 | func (m *MonitorHostMetric) isMonitor() {} 138 | func (m *MonitorServiceMetric) isMonitor() {} 139 | func (m *MonitorExternalHTTP) isMonitor() {} 140 | func (m *MonitorExpression) isMonitor() {} 141 | func (m *MonitorAnomalyDetection) isMonitor() {} 142 | func (m *MonitorQuery) isMonitor() {} 143 | 144 | // MonitorConnectivity represents connectivity monitor. 145 | type MonitorConnectivity struct { 146 | ID string `json:"id,omitempty"` 147 | Name string `json:"name,omitempty"` 148 | Memo string `json:"memo,omitempty"` 149 | AlertStatusOnGone string `json:"alertStatusOnGone,omitempty"` 150 | Type string `json:"type,omitempty"` 151 | IsMute bool `json:"isMute,omitempty"` 152 | NotificationInterval uint64 `json:"notificationInterval,omitempty"` 153 | 154 | Scopes []string `json:"scopes,omitempty"` 155 | ExcludeScopes []string `json:"excludeScopes,omitempty"` 156 | } 157 | 158 | // MonitorType returns monitor type. 159 | func (m *MonitorConnectivity) MonitorType() string { return monitorTypeConnectivity } 160 | 161 | // MonitorName returns monitor name. 162 | func (m *MonitorConnectivity) MonitorName() string { return m.Name } 163 | 164 | // MonitorID returns monitor id. 165 | func (m *MonitorConnectivity) MonitorID() string { return m.ID } 166 | 167 | // MonitorHostMetric represents host metric monitor. 168 | type MonitorHostMetric struct { 169 | ID string `json:"id,omitempty"` 170 | Name string `json:"name,omitempty"` 171 | Memo string `json:"memo,omitempty"` 172 | Type string `json:"type,omitempty"` 173 | IsMute bool `json:"isMute,omitempty"` 174 | NotificationInterval uint64 `json:"notificationInterval,omitempty"` 175 | 176 | Metric string `json:"metric,omitempty"` 177 | Operator string `json:"operator,omitempty"` 178 | Warning *float64 `json:"warning"` 179 | Critical *float64 `json:"critical"` 180 | Duration uint64 `json:"duration,omitempty"` 181 | MaxCheckAttempts uint64 `json:"maxCheckAttempts,omitempty"` 182 | 183 | Scopes []string `json:"scopes,omitempty"` 184 | ExcludeScopes []string `json:"excludeScopes,omitempty"` 185 | } 186 | 187 | // MonitorType returns monitor type. 188 | func (m *MonitorHostMetric) MonitorType() string { return monitorTypeHostMetric } 189 | 190 | // MonitorName returns monitor name. 191 | func (m *MonitorHostMetric) MonitorName() string { return m.Name } 192 | 193 | // MonitorID returns monitor id. 194 | func (m *MonitorHostMetric) MonitorID() string { return m.ID } 195 | 196 | // MonitorServiceMetric represents service metric monitor. 197 | type MonitorServiceMetric struct { 198 | ID string `json:"id,omitempty"` 199 | Name string `json:"name,omitempty"` 200 | Memo string `json:"memo,omitempty"` 201 | Type string `json:"type,omitempty"` 202 | IsMute bool `json:"isMute,omitempty"` 203 | NotificationInterval uint64 `json:"notificationInterval,omitempty"` 204 | 205 | Service string `json:"service,omitempty"` 206 | Metric string `json:"metric,omitempty"` 207 | Operator string `json:"operator,omitempty"` 208 | Warning *float64 `json:"warning"` 209 | Critical *float64 `json:"critical"` 210 | Duration uint64 `json:"duration,omitempty"` 211 | MaxCheckAttempts uint64 `json:"maxCheckAttempts,omitempty"` 212 | MissingDurationWarning uint64 `json:"missingDurationWarning,omitempty"` 213 | MissingDurationCritical uint64 `json:"missingDurationCritical,omitempty"` 214 | } 215 | 216 | // MonitorType returns monitor type. 217 | func (m *MonitorServiceMetric) MonitorType() string { return monitorTypeServiceMetric } 218 | 219 | // MonitorName returns monitor name. 220 | func (m *MonitorServiceMetric) MonitorName() string { return m.Name } 221 | 222 | // MonitorID returns monitor id. 223 | func (m *MonitorServiceMetric) MonitorID() string { return m.ID } 224 | 225 | // MonitorExternalHTTP represents external HTTP monitor. 226 | type MonitorExternalHTTP struct { 227 | ID string `json:"id,omitempty"` 228 | Name string `json:"name,omitempty"` 229 | Memo string `json:"memo,omitempty"` 230 | Type string `json:"type,omitempty"` 231 | IsMute bool `json:"isMute,omitempty"` 232 | NotificationInterval uint64 `json:"notificationInterval,omitempty"` 233 | 234 | Method string `json:"method,omitempty"` 235 | URL string `json:"url,omitempty"` 236 | MaxCheckAttempts uint64 `json:"maxCheckAttempts,omitempty"` 237 | Service string `json:"service,omitempty"` 238 | ResponseTimeCritical *float64 `json:"responseTimeCritical,omitempty"` 239 | ResponseTimeWarning *float64 `json:"responseTimeWarning,omitempty"` 240 | ResponseTimeDuration *uint64 `json:"responseTimeDuration,omitempty"` 241 | RequestBody string `json:"requestBody,omitempty"` 242 | ContainsString string `json:"containsString,omitempty"` 243 | CertificationExpirationCritical *uint64 `json:"certificationExpirationCritical,omitempty"` 244 | CertificationExpirationWarning *uint64 `json:"certificationExpirationWarning,omitempty"` 245 | SkipCertificateVerification bool `json:"skipCertificateVerification,omitempty"` 246 | FollowRedirect bool `json:"followRedirect,omitempty"` 247 | ExpectedStatusCode *int `json:"expectedStatusCode,omitempty"` 248 | // Empty list of headers and nil are different. You have to specify empty 249 | // list as headers explicitly if you want to remove all headers instead of 250 | // using nil. 251 | Headers []HeaderField `json:"headers"` 252 | } 253 | 254 | // HeaderField represents key-value pairs in an HTTP header for external http 255 | // monitoring. 256 | type HeaderField struct { 257 | Name string `json:"name"` 258 | Value string `json:"value"` 259 | } 260 | 261 | // MonitorType returns monitor type. 262 | func (m *MonitorExternalHTTP) MonitorType() string { return monitorTypeExternalHTTP } 263 | 264 | // MonitorName returns monitor name. 265 | func (m *MonitorExternalHTTP) MonitorName() string { return m.Name } 266 | 267 | // MonitorID returns monitor id. 268 | func (m *MonitorExternalHTTP) MonitorID() string { return m.ID } 269 | 270 | // MonitorExpression represents expression monitor. 271 | type MonitorExpression struct { 272 | ID string `json:"id,omitempty"` 273 | Name string `json:"name,omitempty"` 274 | Memo string `json:"memo,omitempty"` 275 | Type string `json:"type,omitempty"` 276 | IsMute bool `json:"isMute,omitempty"` 277 | NotificationInterval uint64 `json:"notificationInterval,omitempty"` 278 | 279 | Expression string `json:"expression,omitempty"` 280 | Operator string `json:"operator,omitempty"` 281 | Warning *float64 `json:"warning"` 282 | Critical *float64 `json:"critical"` 283 | EvaluateBackwardMinutes *uint64 `json:"evaluateBackwardMinutes,omitempty"` 284 | } 285 | 286 | // MonitorType returns monitor type. 287 | func (m *MonitorExpression) MonitorType() string { return monitorTypeExpression } 288 | 289 | // MonitorName returns monitor name. 290 | func (m *MonitorExpression) MonitorName() string { return m.Name } 291 | 292 | // MonitorID returns monitor id. 293 | func (m *MonitorExpression) MonitorID() string { return m.ID } 294 | 295 | // MonitorAnomalyDetection represents anomaly detection monitor. 296 | type MonitorAnomalyDetection struct { 297 | ID string `json:"id,omitempty"` 298 | Name string `json:"name,omitempty"` 299 | Memo string `json:"memo,omitempty"` 300 | Type string `json:"type,omitempty"` 301 | IsMute bool `json:"isMute,omitempty"` 302 | NotificationInterval uint64 `json:"notificationInterval,omitempty"` 303 | 304 | WarningSensitivity string `json:"warningSensitivity,omitempty"` 305 | CriticalSensitivity string `json:"criticalSensitivity,omitempty"` 306 | TrainingPeriodFrom uint64 `json:"trainingPeriodFrom,omitempty"` 307 | MaxCheckAttempts uint64 `json:"maxCheckAttempts,omitempty"` 308 | 309 | Scopes []string `json:"scopes"` 310 | } 311 | 312 | // MonitorType returns monitor type. 313 | func (m *MonitorAnomalyDetection) MonitorType() string { return monitorTypeAnomalyDetection } 314 | 315 | // MonitorName returns monitor name. 316 | func (m *MonitorAnomalyDetection) MonitorName() string { return m.Name } 317 | 318 | // MonitorID returns monitor id. 319 | func (m *MonitorAnomalyDetection) MonitorID() string { return m.ID } 320 | 321 | // MonitorQuery represents query monitor. 322 | type MonitorQuery struct { 323 | ID string `json:"id,omitempty"` 324 | Name string `json:"name,omitempty"` 325 | Memo string `json:"memo,omitempty"` 326 | Type string `json:"type,omitempty"` 327 | IsMute bool `json:"isMute,omitempty"` 328 | NotificationInterval uint64 `json:"notificationInterval,omitempty"` 329 | 330 | Query string `json:"query,omitempty"` 331 | Operator string `json:"operator,omitempty"` 332 | Warning *float64 `json:"warning"` 333 | Critical *float64 `json:"critical"` 334 | Legend string `json:"legend,omitempty"` 335 | EvaluateBackwardMinutes *uint64 `json:"evaluateBackwardMinutes,omitempty"` 336 | } 337 | 338 | // MonitorType returns monitor type. 339 | func (m *MonitorQuery) MonitorType() string { return monitorTypeQuery } 340 | 341 | // MonitorName returns monitor name. 342 | func (m *MonitorQuery) MonitorName() string { return m.Name } 343 | 344 | // MonitorID returns monitor id. 345 | func (m *MonitorQuery) MonitorID() string { return m.ID } 346 | 347 | // FindMonitors finds monitors. 348 | func (c *Client) FindMonitors() ([]Monitor, error) { 349 | data, err := requestGet[struct { 350 | Monitors []json.RawMessage `json:"monitors"` 351 | }](c, "/api/v0/monitors") 352 | if err != nil { 353 | return nil, err 354 | } 355 | ms := make([]Monitor, 0, len(data.Monitors)) 356 | for _, rawmes := range data.Monitors { 357 | m, err := decodeMonitor(rawmes) 358 | var e *unknownMonitorTypeError 359 | if err != nil { 360 | if errors.As(err, &e) { 361 | break 362 | } 363 | return nil, err 364 | } 365 | ms = append(ms, m) 366 | } 367 | return ms, err 368 | } 369 | 370 | // GetMonitor gets a monitor. 371 | func (c *Client) GetMonitor(monitorID string) (Monitor, error) { 372 | data, err := requestGet[struct { 373 | Monitor json.RawMessage `json:"monitor"` 374 | }](c, fmt.Sprintf("/api/v0/monitors/%s", monitorID)) 375 | if err != nil { 376 | return nil, err 377 | } 378 | m, err := decodeMonitor(data.Monitor) 379 | if err != nil { 380 | return nil, err 381 | } 382 | return m, err 383 | } 384 | 385 | // CreateMonitor creates a monitor. 386 | func (c *Client) CreateMonitor(param Monitor) (Monitor, error) { 387 | data, err := requestPost[json.RawMessage](c, "/api/v0/monitors", param) 388 | if err != nil { 389 | return nil, err 390 | } 391 | return decodeMonitor(*data) 392 | } 393 | 394 | // UpdateMonitor updates a monitor. 395 | func (c *Client) UpdateMonitor(monitorID string, param Monitor) (Monitor, error) { 396 | path := fmt.Sprintf("/api/v0/monitors/%s", monitorID) 397 | data, err := requestPut[json.RawMessage](c, path, param) 398 | if err != nil { 399 | return nil, err 400 | } 401 | return decodeMonitor(*data) 402 | } 403 | 404 | // DeleteMonitor updates a monitor. 405 | func (c *Client) DeleteMonitor(monitorID string) (Monitor, error) { 406 | path := fmt.Sprintf("/api/v0/monitors/%s", monitorID) 407 | data, err := requestDelete[json.RawMessage](c, path) 408 | if err != nil { 409 | return nil, err 410 | } 411 | return decodeMonitor(*data) 412 | } 413 | 414 | type unknownMonitorTypeError struct { 415 | Type string 416 | } 417 | 418 | func (e *unknownMonitorTypeError) Error() string { 419 | return fmt.Sprintf("unknown monitor type: %s", e.Type) 420 | } 421 | 422 | // decodeMonitor decodes json.RawMessage and returns monitor. 423 | func decodeMonitor(mes json.RawMessage) (Monitor, error) { 424 | var typeData struct { 425 | Type string `json:"type"` 426 | } 427 | if err := json.Unmarshal(mes, &typeData); err != nil { 428 | return nil, err 429 | } 430 | var m Monitor 431 | switch typeData.Type { 432 | case monitorTypeConnectivity: 433 | m = &MonitorConnectivity{} 434 | case monitorTypeHostMetric: 435 | m = &MonitorHostMetric{} 436 | case monitorTypeServiceMetric: 437 | m = &MonitorServiceMetric{} 438 | case monitorTypeExternalHTTP: 439 | m = &MonitorExternalHTTP{} 440 | case monitorTypeExpression: 441 | m = &MonitorExpression{} 442 | case monitorTypeAnomalyDetection: 443 | m = &MonitorAnomalyDetection{} 444 | case monitorTypeQuery: 445 | m = &MonitorQuery{} 446 | default: 447 | return nil, &unknownMonitorTypeError{Type: typeData.Type} 448 | } 449 | if err := json.Unmarshal(mes, m); err != nil { 450 | return nil, err 451 | } 452 | return m, nil 453 | } 454 | 455 | func decodeMonitorReader(r io.Reader) (Monitor, error) { 456 | b, err := io.ReadAll(r) 457 | if err != nil { 458 | return nil, err 459 | } 460 | return decodeMonitor(b) 461 | } 462 | -------------------------------------------------------------------------------- /notification_groups.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import "fmt" 4 | 5 | // NotificationLevel represents a level of notification. 6 | type NotificationLevel string 7 | 8 | // NotificationLevels 9 | const ( 10 | NotificationLevelAll NotificationLevel = "all" 11 | NotificationLevelCritical NotificationLevel = "critical" 12 | ) 13 | 14 | // NotificationGroup represents a Mackerel notification group. 15 | // ref. https://mackerel.io/api-docs/entry/notification-groups 16 | type NotificationGroup struct { 17 | ID string `json:"id,omitempty"` 18 | Name string `json:"name"` 19 | NotificationLevel NotificationLevel `json:"notificationLevel"` 20 | ChildNotificationGroupIDs []string `json:"childNotificationGroupIds"` 21 | ChildChannelIDs []string `json:"childChannelIds"` 22 | Monitors []*NotificationGroupMonitor `json:"monitors,omitempty"` 23 | Services []*NotificationGroupService `json:"services,omitempty"` 24 | } 25 | 26 | // NotificationGroupMonitor represents a notification target monitor rule. 27 | type NotificationGroupMonitor struct { 28 | ID string `json:"id"` 29 | SkipDefault bool `json:"skipDefault"` 30 | } 31 | 32 | // NotificationGroupService represents a notification target service. 33 | type NotificationGroupService struct { 34 | Name string `json:"name"` 35 | } 36 | 37 | // FindNotificationGroups finds notification groups. 38 | func (c *Client) FindNotificationGroups() ([]*NotificationGroup, error) { 39 | data, err := requestGet[struct { 40 | NotificationGroups []*NotificationGroup `json:"notificationGroups"` 41 | }](c, "/api/v0/notification-groups") 42 | if err != nil { 43 | return nil, err 44 | } 45 | return data.NotificationGroups, nil 46 | } 47 | 48 | // CreateNotificationGroup creates a notification group. 49 | func (c *Client) CreateNotificationGroup(param *NotificationGroup) (*NotificationGroup, error) { 50 | return requestPost[NotificationGroup](c, "/api/v0/notification-groups", param) 51 | } 52 | 53 | // UpdateNotificationGroup updates a notification group. 54 | func (c *Client) UpdateNotificationGroup(id string, param *NotificationGroup) (*NotificationGroup, error) { 55 | path := fmt.Sprintf("/api/v0/notification-groups/%s", id) 56 | return requestPut[NotificationGroup](c, path, param) 57 | } 58 | 59 | // DeleteNotificationGroup deletes a notification group. 60 | func (c *Client) DeleteNotificationGroup(id string) (*NotificationGroup, error) { 61 | path := fmt.Sprintf("/api/v0/notification-groups/%s", id) 62 | return requestDelete[NotificationGroup](c, path) 63 | } 64 | -------------------------------------------------------------------------------- /notification_groups_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/kylelemons/godebug/pretty" 12 | ) 13 | 14 | func TestCreateNotificationGroup(t *testing.T) { 15 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 16 | if req.URL.Path != "/api/v0/notification-groups" { 17 | t.Error("request URL should be /api/v0/notification-groups but: ", req.URL.Path) 18 | } 19 | if req.Method != "POST" { 20 | t.Error("request method should be POST but: ", req.Method) 21 | } 22 | 23 | body, _ := io.ReadAll(req.Body) 24 | 25 | var notificationGroup NotificationGroup 26 | if err := json.Unmarshal(body, ¬ificationGroup); err != nil { 27 | t.Fatal("request body should be decoded as json", string(body)) 28 | } 29 | 30 | respJSON, _ := json.Marshal(map[string]interface{}{ 31 | "id": "3JwREyrZGQ9", 32 | "name": notificationGroup.Name, 33 | "notificationLevel": notificationGroup.NotificationLevel, 34 | "childNotificationGroupIds": notificationGroup.ChildNotificationGroupIDs, 35 | "childChannelIds": notificationGroup.ChildChannelIDs, 36 | "monitors": notificationGroup.Monitors, 37 | "services": notificationGroup.Services, 38 | }) 39 | 40 | res.Header()["Content-Type"] = []string{"application/json"} 41 | _, _ = fmt.Fprint(res, string(respJSON)) 42 | })) 43 | defer ts.Close() 44 | 45 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 46 | 47 | param := &NotificationGroup{ 48 | Name: "New Notification Group", 49 | NotificationLevel: NotificationLevelAll, 50 | ChildNotificationGroupIDs: []string{"3mUMcLB4ks9", "2w53XJsufQG"}, 51 | ChildChannelIDs: []string{"2nckL8bKy6E", "2w54SREy99h", "3JwPUGFJw2f"}, 52 | Monitors: []*NotificationGroupMonitor{ 53 | {ID: "2CRrhj1SFwG", SkipDefault: true}, 54 | {ID: "3TdoBWxYRQd", SkipDefault: false}, 55 | }, 56 | Services: []*NotificationGroupService{ 57 | {Name: "my-service-01"}, 58 | {Name: "my-service-02"}, 59 | }, 60 | } 61 | 62 | got, err := client.CreateNotificationGroup(param) 63 | if err != nil { 64 | t.Error("err should be nil but: ", err) 65 | } 66 | 67 | want := &NotificationGroup{ 68 | ID: "3JwREyrZGQ9", 69 | Name: param.Name, 70 | NotificationLevel: param.NotificationLevel, 71 | ChildNotificationGroupIDs: param.ChildNotificationGroupIDs, 72 | ChildChannelIDs: param.ChildChannelIDs, 73 | Monitors: param.Monitors, 74 | Services: param.Services, 75 | } 76 | 77 | if diff := pretty.Compare(got, want); diff != "" { 78 | t.Errorf("fail to get correct data: diff (-got +want)\n%s", diff) 79 | } 80 | } 81 | 82 | func TestFindNotificationGroups(t *testing.T) { 83 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 84 | if req.URL.Path != "/api/v0/notification-groups" { 85 | t.Error("request URL should be /api/v0/notification-groups but: ", req.URL.Path) 86 | } 87 | if req.Method != "GET" { 88 | t.Error("request method should be GET but: ", req.Method) 89 | } 90 | 91 | respJSON, _ := json.Marshal(map[string][]map[string]interface{}{ 92 | "notificationGroups": { 93 | { 94 | "id": "3Ja3HG3bTwq", 95 | "name": "Default", 96 | "notificationLevel": "all", 97 | "childNotificationGroupIDs": []string{}, 98 | "childChannelIDs": []string{"3Ja3HG3VTaA"}, 99 | }, 100 | { 101 | "id": "3UJaU9eREvw", 102 | "name": "Notification Group #01", 103 | "notificationLevel": "all", 104 | "childNotificationGroupIds": []string{"3Tdq1pz9aLm"}, 105 | "childChannelIds": []string{}, 106 | }, 107 | { 108 | "id": "3Tdq1pz9aLm", 109 | "name": "Notification Group #02", 110 | "notificationLevel": "critical", 111 | "childNotificationGroupIds": []string{}, 112 | "childChannelIds": []string{}, 113 | "monitors": []map[string]interface{}{ 114 | {"id": "3Ja3HG5Mngw", "skipDefault": false}, 115 | }, 116 | "services": []map[string]string{ 117 | {"name": "my-service-01"}, 118 | }, 119 | }, 120 | }, 121 | }) 122 | 123 | res.Header()["Content-Type"] = []string{"application/json"} 124 | _, _ = fmt.Fprint(res, string(respJSON)) 125 | })) 126 | defer ts.Close() 127 | 128 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 129 | 130 | got, err := client.FindNotificationGroups() 131 | if err != nil { 132 | t.Error("err should be nil but: ", err) 133 | } 134 | 135 | want := []*NotificationGroup{ 136 | { 137 | ID: "3Ja3HG3bTwq", 138 | Name: "Default", 139 | NotificationLevel: NotificationLevelAll, 140 | ChildNotificationGroupIDs: []string{}, 141 | ChildChannelIDs: []string{"3Ja3HG3VTaA"}, 142 | }, 143 | { 144 | ID: "3UJaU9eREvw", 145 | Name: "Notification Group #01", 146 | NotificationLevel: NotificationLevelAll, 147 | ChildNotificationGroupIDs: []string{"3Tdq1pz9aLm"}, 148 | ChildChannelIDs: []string{}, 149 | }, 150 | { 151 | ID: "3Tdq1pz9aLm", 152 | Name: "Notification Group #02", 153 | NotificationLevel: NotificationLevelCritical, 154 | ChildNotificationGroupIDs: []string{}, 155 | ChildChannelIDs: []string{}, 156 | Monitors: []*NotificationGroupMonitor{ 157 | {ID: "3Ja3HG5Mngw", SkipDefault: false}, 158 | }, 159 | Services: []*NotificationGroupService{ 160 | {Name: "my-service-01"}, 161 | }, 162 | }, 163 | } 164 | 165 | if diff := pretty.Compare(got, want); diff != "" { 166 | t.Errorf("fail to get correct data: diff: (-got +want)\n%s", diff) 167 | } 168 | } 169 | 170 | func TestUpdateNotificationGroup(t *testing.T) { 171 | id := "xxxxxxxxxxx" 172 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 173 | if req.URL.Path != fmt.Sprintf("/api/v0/notification-groups/%s", id) { 174 | t.Error("request URL should be /api/v0/notification-groups/ but: ", req.URL.Path) 175 | } 176 | if req.Method != "PUT" { 177 | t.Error("request method should be PUT but: ", req.Method) 178 | } 179 | 180 | body, _ := io.ReadAll(req.Body) 181 | 182 | var notificationGroup NotificationGroup 183 | if err := json.Unmarshal(body, ¬ificationGroup); err != nil { 184 | t.Fatal("request body should be decoded as json", string(body)) 185 | } 186 | 187 | respJSON, _ := json.Marshal(map[string]interface{}{ 188 | "id": id, 189 | "name": notificationGroup.Name, 190 | "notificationLevel": notificationGroup.NotificationLevel, 191 | "childNotificationGroupIds": notificationGroup.ChildNotificationGroupIDs, 192 | "childChannelIds": notificationGroup.ChildChannelIDs, 193 | "monitors": notificationGroup.Monitors, 194 | "services": notificationGroup.Services, 195 | }) 196 | 197 | res.Header()["Content-Type"] = []string{"application/json"} 198 | _, _ = fmt.Fprint(res, string(respJSON)) 199 | })) 200 | defer ts.Close() 201 | 202 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 203 | 204 | param := &NotificationGroup{ 205 | Name: "New Notification Group", 206 | NotificationLevel: NotificationLevelCritical, 207 | ChildNotificationGroupIDs: []string{"3mUMcLB4ks9", "2w53XJsufQG"}, 208 | ChildChannelIDs: []string{"2nckL8bKy6E", "2w54SREy99h", "3JwPUGFJw2f"}, 209 | Monitors: []*NotificationGroupMonitor{ 210 | {ID: "2CRrhj1SFwG", SkipDefault: true}, 211 | {ID: "3TdoBWxYRQd", SkipDefault: false}, 212 | }, 213 | Services: []*NotificationGroupService{ 214 | {Name: "my-service-01"}, 215 | {Name: "my-service-02"}, 216 | }, 217 | } 218 | 219 | got, err := client.UpdateNotificationGroup(id, param) 220 | if err != nil { 221 | t.Error("err should be nil but: ", err) 222 | } 223 | 224 | want := &NotificationGroup{ 225 | ID: id, 226 | Name: param.Name, 227 | NotificationLevel: param.NotificationLevel, 228 | ChildNotificationGroupIDs: param.ChildNotificationGroupIDs, 229 | ChildChannelIDs: param.ChildChannelIDs, 230 | Monitors: param.Monitors, 231 | Services: param.Services, 232 | } 233 | 234 | if diff := pretty.Compare(got, want); diff != "" { 235 | t.Errorf("fail to get correct data: diff (-got +want)\n%s", diff) 236 | } 237 | } 238 | 239 | func TestDeleteNotificationGroup(t *testing.T) { 240 | id := "xxxxxxxxxxx" 241 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 242 | if req.URL.Path != fmt.Sprintf("/api/v0/notification-groups/%s", id) { 243 | t.Error("request URL should be /api/v0/notification-groups/ but: ", req.URL.Path) 244 | } 245 | if req.Method != "DELETE" { 246 | t.Error("request method should be DELETE but: ", req.Method) 247 | } 248 | 249 | respJSON, _ := json.Marshal(map[string]interface{}{ 250 | "id": id, 251 | "name": "My Notification Group", 252 | "notificationLevel": "all", 253 | "childNotificationGroupIds": []string{}, 254 | "childChannelIds": []string{}, 255 | "monitors": []map[string]interface{}{ 256 | {"id": "2CRrhj1SFwG", "skipDefault": true}, 257 | }, 258 | "services": []map[string]string{ 259 | {"name": "my-service-01"}, 260 | }, 261 | }) 262 | 263 | res.Header()["Content-Type"] = []string{"application/json"} 264 | _, _ = fmt.Fprint(res, string(respJSON)) 265 | })) 266 | defer ts.Close() 267 | 268 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 269 | 270 | got, err := client.DeleteNotificationGroup(id) 271 | if err != nil { 272 | t.Error("err should be nil but: ", err) 273 | } 274 | 275 | want := &NotificationGroup{ 276 | ID: id, 277 | Name: "My Notification Group", 278 | NotificationLevel: NotificationLevelAll, 279 | ChildNotificationGroupIDs: []string{}, 280 | ChildChannelIDs: []string{}, 281 | Monitors: []*NotificationGroupMonitor{ 282 | {ID: "2CRrhj1SFwG", SkipDefault: true}, 283 | }, 284 | Services: []*NotificationGroupService{ 285 | {Name: "my-service-01"}, 286 | }, 287 | } 288 | 289 | if diff := pretty.Compare(got, want); diff != "" { 290 | t.Errorf("fail to get correct data: diff (-got +want)\n%s", diff) 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /org.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | // Org information 4 | type Org struct { 5 | Name string `json:"name"` 6 | DisplayName string `json:"displayName,omitempty"` 7 | } 8 | 9 | // GetOrg gets the org. 10 | func (c *Client) GetOrg() (*Org, error) { 11 | return requestGet[Org](c, "/api/v0/org") 12 | } 13 | -------------------------------------------------------------------------------- /org_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestGetOrg(t *testing.T) { 12 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 13 | if req.URL.Path != "/api/v0/org" { 14 | t.Error("request URL should be /api/v0/org but: ", req.URL.Path) 15 | } 16 | 17 | if req.Method != "GET" { 18 | t.Error("request method should be GET but: ", req.Method) 19 | } 20 | respJSON, _ := json.Marshal(&Org{Name: "hoge"}) 21 | 22 | res.Header()["Content-Type"] = []string{"application/json"} 23 | fmt.Fprint(res, string(respJSON)) 24 | })) 25 | defer ts.Close() 26 | 27 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 28 | org, err := client.GetOrg() 29 | if err != nil { 30 | t.Error("err should be nil but: ", err) 31 | } 32 | 33 | if org.Name != "hoge" { 34 | t.Error("request sends json including Name but: ", org) 35 | } 36 | 37 | if org.DisplayName != "" { 38 | t.Error("request sends json not including DisplayName but: ", org) 39 | } 40 | } 41 | 42 | func TestGetOrgWithDisplayName(t *testing.T) { 43 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 44 | if req.URL.Path != "/api/v0/org" { 45 | t.Error("request URL should be /api/v0/org but: ", req.URL.Path) 46 | } 47 | 48 | if req.Method != "GET" { 49 | t.Error("request method should be GET but: ", req.Method) 50 | } 51 | respJSON, _ := json.Marshal(&Org{Name: "hoge", DisplayName: "fuga"}) 52 | 53 | res.Header()["Content-Type"] = []string{"application/json"} 54 | fmt.Fprint(res, string(respJSON)) 55 | })) 56 | defer ts.Close() 57 | 58 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 59 | org, err := client.GetOrg() 60 | if err != nil { 61 | t.Error("err should be nil but: ", err) 62 | } 63 | 64 | if org.Name != "hoge" { 65 | t.Error("request sends json including Name but: ", org) 66 | } 67 | 68 | if org.DisplayName != "fuga" { 69 | t.Error("request sends json including DisplayName but: ", org) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /role_metadata.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // https://mackerel.io/ja/api-docs/entry/metadata 10 | 11 | // RoleMetaDataResp represents response for role metadata. 12 | type RoleMetaDataResp struct { 13 | RoleMetaData RoleMetaData 14 | LastModified time.Time 15 | } 16 | 17 | // RoleMetaData represents role metadata body. 18 | type RoleMetaData interface{} 19 | 20 | // GetRoleMetaData gets a role metadata. 21 | func (c *Client) GetRoleMetaData(serviceName, roleName, namespace string) (*RoleMetaDataResp, error) { 22 | path := fmt.Sprintf("/api/v0/services/%s/roles/%s/metadata/%s", serviceName, roleName, namespace) 23 | metadata, header, err := requestGetAndReturnHeader[HostMetaData](c, path) 24 | if err != nil { 25 | return nil, err 26 | } 27 | lastModified, err := http.ParseTime(header.Get("Last-Modified")) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &RoleMetaDataResp{RoleMetaData: *metadata, LastModified: lastModified}, nil 32 | } 33 | 34 | // GetRoleMetaDataNameSpaces fetches namespaces of role metadata. 35 | func (c *Client) GetRoleMetaDataNameSpaces(serviceName, roleName string) ([]string, error) { 36 | data, err := requestGet[struct { 37 | MetaDatas []struct { 38 | NameSpace string `json:"namespace"` 39 | } `json:"metadata"` 40 | }](c, fmt.Sprintf("/api/v0/services/%s/roles/%s/metadata", serviceName, roleName)) 41 | if err != nil { 42 | return nil, err 43 | } 44 | namespaces := make([]string, len(data.MetaDatas)) 45 | for i, metadata := range data.MetaDatas { 46 | namespaces[i] = metadata.NameSpace 47 | } 48 | return namespaces, nil 49 | } 50 | 51 | // PutRoleMetaData puts a role metadata. 52 | func (c *Client) PutRoleMetaData(serviceName, roleName, namespace string, metadata RoleMetaData) error { 53 | path := fmt.Sprintf("/api/v0/services/%s/roles/%s/metadata/%s", serviceName, roleName, namespace) 54 | _, err := requestPut[any](c, path, metadata) 55 | return err 56 | } 57 | 58 | // DeleteRoleMetaData deletes a role metadata. 59 | func (c *Client) DeleteRoleMetaData(serviceName, roleName, namespace string) error { 60 | path := fmt.Sprintf("/api/v0/services/%s/roles/%s/metadata/%s", serviceName, roleName, namespace) 61 | _, err := requestDelete[any](c, path) 62 | return err 63 | } 64 | -------------------------------------------------------------------------------- /role_metadata_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestGetRoleMetaData(t *testing.T) { 14 | var ( 15 | serviceName = "testService" 16 | roleName = "testRole" 17 | namespace = "testing" 18 | lastModified = time.Date(2018, 3, 6, 3, 0, 0, 0, time.UTC) 19 | ) 20 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 21 | u := fmt.Sprintf("/api/v0/services/%s/roles/%s/metadata/%s", serviceName, roleName, namespace) 22 | if req.URL.Path != u { 23 | t.Errorf("request URL should be %v but %v:", u, req.URL.Path) 24 | } 25 | 26 | if req.Method != "GET" { 27 | t.Error("request method should be GET but: ", req.Method) 28 | } 29 | 30 | respJSON := `{"type":12345,"region":"jp","env":"staging","instance_type":"c4.xlarge"}` 31 | res.Header()["Content-Type"] = []string{"application/json"} 32 | res.Header()["Last-Modified"] = []string{lastModified.Format(http.TimeFormat)} 33 | fmt.Fprint(res, respJSON) 34 | })) 35 | defer ts.Close() 36 | 37 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 38 | metadataResp, err := client.GetRoleMetaData(serviceName, roleName, namespace) 39 | if err != nil { 40 | t.Error("err should be nil but: ", err) 41 | } 42 | 43 | metadata := metadataResp.RoleMetaData 44 | if metadata.(map[string]interface{})["type"].(float64) != 12345 { 45 | t.Errorf("got: %v, want: %v", metadata.(map[string]interface{})["type"], 12345) 46 | } 47 | if metadata.(map[string]interface{})["region"] != "jp" { 48 | t.Errorf("got: %v, want: %v", metadata.(map[string]interface{})["region"], "jp") 49 | } 50 | if metadata.(map[string]interface{})["env"] != "staging" { 51 | t.Errorf("got: %v, want: %v", metadata.(map[string]interface{})["env"], "staging") 52 | } 53 | if metadata.(map[string]interface{})["instance_type"] != "c4.xlarge" { 54 | t.Errorf("got: %v, want: %v", metadata.(map[string]interface{})["instance_type"], "c4.xlarge") 55 | } 56 | if !metadataResp.LastModified.Equal(lastModified) { 57 | t.Errorf("got: %v, want: %v", metadataResp.LastModified, lastModified) 58 | } 59 | } 60 | 61 | func TestGetRoleMetaDataNameSpaces(t *testing.T) { 62 | var ( 63 | serviceName = "testService" 64 | roleName = "testRole" 65 | ) 66 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 67 | u := fmt.Sprintf("/api/v0/services/%s/roles/%s/metadata", serviceName, roleName) 68 | if req.URL.Path != u { 69 | t.Errorf("request URL should be %v but %v:", u, req.URL.Path) 70 | } 71 | 72 | if req.Method != "GET" { 73 | t.Error("request method should be GET but: ", req.Method) 74 | } 75 | 76 | respJSON := `{"metadata":[{"namespace":"testing1"}, {"namespace":"testing2"}]}` 77 | res.Header()["Content-Type"] = []string{"application/json"} 78 | fmt.Fprint(res, respJSON) 79 | })) 80 | defer ts.Close() 81 | 82 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 83 | namespaces, err := client.GetRoleMetaDataNameSpaces(serviceName, roleName) 84 | if err != nil { 85 | t.Error("err should be nil but: ", err) 86 | } 87 | 88 | if !reflect.DeepEqual(namespaces, []string{"testing1", "testing2"}) { 89 | t.Errorf("got: %v, want: %v", namespaces, []string{"testing1", "testing2"}) 90 | } 91 | } 92 | 93 | func TestPutRoleMetaData(t *testing.T) { 94 | var ( 95 | serviceName = "testService" 96 | roleName = "testRole" 97 | namespace = "testing" 98 | ) 99 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 100 | u := fmt.Sprintf("/api/v0/services/%s/roles/%s/metadata/%s", serviceName, roleName, namespace) 101 | if req.URL.Path != u { 102 | t.Errorf("request URL should be %v but %v:", u, req.URL.Path) 103 | } 104 | 105 | if req.Method != "PUT" { 106 | t.Error("request method should be PUT but: ", req.Method) 107 | } 108 | 109 | body, _ := io.ReadAll(req.Body) 110 | reqJSON := `{"env":"staging","instance_type":"c4.xlarge","region":"jp","type":12345}` + "\n" 111 | if string(body) != reqJSON { 112 | t.Errorf("request body should be %v but %v", reqJSON, string(body)) 113 | } 114 | 115 | res.Header()["Content-Type"] = []string{"application/json"} 116 | fmt.Fprint(res, `{"success":true}`) 117 | })) 118 | defer ts.Close() 119 | 120 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 121 | metadata := map[string]interface{}{ 122 | "type": 12345, 123 | "region": "jp", 124 | "env": "staging", 125 | "instance_type": "c4.xlarge", 126 | } 127 | err := client.PutRoleMetaData(serviceName, roleName, namespace, &metadata) 128 | if err != nil { 129 | t.Error("err should be nil but: ", err) 130 | } 131 | } 132 | 133 | func TestDeleteRoleMetaData(t *testing.T) { 134 | var ( 135 | serviceName = "testService" 136 | roleName = "testRole" 137 | namespace = "testing" 138 | ) 139 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 140 | u := fmt.Sprintf("/api/v0/services/%s/roles/%s/metadata/%s", serviceName, roleName, namespace) 141 | if req.URL.Path != u { 142 | t.Errorf("request URL should be %v but %v:", u, req.URL.Path) 143 | } 144 | 145 | if req.Method != "DELETE" { 146 | t.Error("request method should be DELETE but: ", req.Method) 147 | } 148 | 149 | res.Header()["Content-Type"] = []string{"application/json"} 150 | fmt.Fprint(res, `{"success":true}`) 151 | })) 152 | defer ts.Close() 153 | 154 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 155 | err := client.DeleteRoleMetaData(serviceName, roleName, namespace) 156 | if err != nil { 157 | t.Error("err should be nil but: ", err) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /roles.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import "fmt" 4 | 5 | // Role represents Mackerel "role". 6 | type Role struct { 7 | Name string `json:"name"` 8 | Memo string `json:"memo"` 9 | } 10 | 11 | // CreateRoleParam parameters for CreateRole 12 | type CreateRoleParam Role 13 | 14 | // FindRoles finds roles. 15 | func (c *Client) FindRoles(serviceName string) ([]*Role, error) { 16 | data, err := requestGet[struct { 17 | Roles []*Role `json:"roles"` 18 | }](c, fmt.Sprintf("/api/v0/services/%s/roles", serviceName)) 19 | if err != nil { 20 | return nil, err 21 | } 22 | return data.Roles, nil 23 | } 24 | 25 | // CreateRole creates a role. 26 | func (c *Client) CreateRole(serviceName string, param *CreateRoleParam) (*Role, error) { 27 | path := fmt.Sprintf("/api/v0/services/%s/roles", serviceName) 28 | return requestPost[Role](c, path, param) 29 | } 30 | 31 | // DeleteRole deletes a role. 32 | func (c *Client) DeleteRole(serviceName, roleName string) (*Role, error) { 33 | path := fmt.Sprintf("/api/v0/services/%s/roles/%s", serviceName, roleName) 34 | return requestDelete[Role](c, path) 35 | } 36 | -------------------------------------------------------------------------------- /roles_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | ) 11 | 12 | func TestFindRoles(t *testing.T) { 13 | testServiceName := "testService" 14 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 15 | uri := fmt.Sprintf("/api/v0/services/%s/roles", testServiceName) 16 | if req.URL.Path != uri { 17 | t.Error("request URL should be ", uri, " but: ", req.URL.Path) 18 | } 19 | 20 | if req.Method != "GET" { 21 | t.Error("request method should be GET but: ", req.Method) 22 | } 23 | 24 | respJSON, _ := json.Marshal(map[string][]map[string]interface{}{ 25 | "roles": { 26 | { 27 | "name": "My-Role", 28 | "memo": "hello", 29 | }, 30 | }, 31 | }) 32 | 33 | res.Header()["Content-Type"] = []string{"application/json"} 34 | fmt.Fprint(res, string(respJSON)) 35 | })) 36 | defer ts.Close() 37 | 38 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 39 | roles, err := client.FindRoles(testServiceName) 40 | 41 | if err != nil { 42 | t.Error("err should be nil but: ", err) 43 | } 44 | 45 | if roles[0].Memo != "hello" { 46 | t.Error("request sends json including memo but: ", roles[0]) 47 | } 48 | } 49 | 50 | func TestCreateRole(t *testing.T) { 51 | testServiceName := "testService" 52 | testRoleName := "testRole" 53 | testRoleMemo := "this role is test" 54 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 55 | uri := fmt.Sprintf("/api/v0/services/%s/roles", testServiceName) 56 | if req.URL.Path != uri { 57 | t.Error("request URL should be ", uri, " but: ", req.URL.Path) 58 | } 59 | 60 | if req.Method != "POST" { 61 | t.Error("request method should be POST but: ", req.Method) 62 | } 63 | 64 | body, err := io.ReadAll(req.Body) 65 | if err != nil { 66 | t.Error(err.Error()) 67 | } 68 | 69 | var reqBody CreateRoleParam 70 | err = json.Unmarshal(body, &reqBody) 71 | if err != nil { 72 | t.Error(err.Error()) 73 | } 74 | 75 | if reqBody.Name != testRoleName { 76 | t.Error("name (in request json parameter) should be ", testRoleName, "but: ", reqBody.Name) 77 | } 78 | 79 | if reqBody.Memo != testRoleMemo { 80 | t.Error("memo (in request json parameter) should be ", testRoleMemo, "but: ", reqBody.Memo) 81 | } 82 | 83 | respJSON, _ := json.Marshal(map[string]interface{}{ 84 | "name": testRoleName, 85 | "memo": testRoleMemo, 86 | }) 87 | 88 | res.Header()["Content-Type"] = []string{"application/json"} 89 | fmt.Fprint(res, string(respJSON)) 90 | })) 91 | defer ts.Close() 92 | 93 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 94 | 95 | role, err := client.CreateRole( 96 | testServiceName, 97 | &CreateRoleParam{ 98 | Name: testRoleName, 99 | Memo: testRoleMemo, 100 | }) 101 | 102 | if err != nil { 103 | t.Error("err should be nil but: ", err) 104 | } 105 | 106 | if role.Name != testRoleName { 107 | t.Error("request sends json including name but: ", role.Name) 108 | } 109 | 110 | if role.Memo != testRoleMemo { 111 | t.Error("request sends json including memo but: ", role.Memo) 112 | } 113 | } 114 | 115 | func TestDeleteRole(t *testing.T) { 116 | testServiceName := "testService" 117 | testRoleName := "testRole" 118 | testRoleMemo := "this role is test" 119 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 120 | uri := fmt.Sprintf("/api/v0/services/%s/roles/%s", testServiceName, testRoleName) 121 | if req.URL.Path != uri { 122 | t.Error("request URL should be ", uri, " but: ", req.URL.Path) 123 | } 124 | 125 | if req.Method != "DELETE" { 126 | t.Error("request method should be DELETE but: ", req.Method) 127 | } 128 | 129 | respJSON, _ := json.Marshal(map[string]interface{}{ 130 | "name": testRoleName, 131 | "memo": testRoleMemo, 132 | }) 133 | 134 | res.Header()["Content-Type"] = []string{"application/json"} 135 | fmt.Fprint(res, string(respJSON)) 136 | })) 137 | defer ts.Close() 138 | 139 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 140 | 141 | role, err := client.DeleteRole(testServiceName, testRoleName) 142 | 143 | if err != nil { 144 | t.Error("err should be nil but: ", err) 145 | } 146 | 147 | if role.Name != testRoleName { 148 | t.Error("request sends json including name but: ", role.Name) 149 | } 150 | 151 | if role.Memo != testRoleMemo { 152 | t.Error("request sends json including memo but: ", role.Memo) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /service_metadata.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // https://mackerel.io/ja/api-docs/entry/metadata 10 | 11 | // ServiceMetaDataResp represents response for service metadata. 12 | type ServiceMetaDataResp struct { 13 | ServiceMetaData ServiceMetaData 14 | LastModified time.Time 15 | } 16 | 17 | // ServiceMetaData represents service metadata body. 18 | type ServiceMetaData interface{} 19 | 20 | // GetServiceMetaData gets service metadata. 21 | func (c *Client) GetServiceMetaData(serviceName, namespace string) (*ServiceMetaDataResp, error) { 22 | path := fmt.Sprintf("/api/v0/services/%s/metadata/%s", serviceName, namespace) 23 | metadata, header, err := requestGetAndReturnHeader[HostMetaData](c, path) 24 | if err != nil { 25 | return nil, err 26 | } 27 | lastModified, err := http.ParseTime(header.Get("Last-Modified")) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &ServiceMetaDataResp{ServiceMetaData: *metadata, LastModified: lastModified}, nil 32 | } 33 | 34 | // GetServiceMetaDataNameSpaces fetches namespaces of service metadata. 35 | func (c *Client) GetServiceMetaDataNameSpaces(serviceName string) ([]string, error) { 36 | data, err := requestGet[struct { 37 | MetaDatas []struct { 38 | NameSpace string `json:"namespace"` 39 | } `json:"metadata"` 40 | }](c, fmt.Sprintf("/api/v0/services/%s/metadata", serviceName)) 41 | if err != nil { 42 | return nil, err 43 | } 44 | namespaces := make([]string, len(data.MetaDatas)) 45 | for i, metadata := range data.MetaDatas { 46 | namespaces[i] = metadata.NameSpace 47 | } 48 | return namespaces, nil 49 | } 50 | 51 | // PutServiceMetaData puts a service metadata. 52 | func (c *Client) PutServiceMetaData(serviceName, namespace string, metadata ServiceMetaData) error { 53 | path := fmt.Sprintf("/api/v0/services/%s/metadata/%s", serviceName, namespace) 54 | _, err := requestPut[any](c, path, metadata) 55 | return err 56 | } 57 | 58 | // DeleteServiceMetaData deletes a service metadata. 59 | func (c *Client) DeleteServiceMetaData(serviceName, namespace string) error { 60 | path := fmt.Sprintf("/api/v0/services/%s/metadata/%s", serviceName, namespace) 61 | _, err := requestDelete[any](c, path) 62 | return err 63 | } 64 | -------------------------------------------------------------------------------- /service_metadata_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestGetServiceMetaData(t *testing.T) { 14 | var ( 15 | serviceName = "testService" 16 | namespace = "testing" 17 | lastModified = time.Date(2018, 3, 6, 3, 0, 0, 0, time.UTC) 18 | ) 19 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 20 | u := fmt.Sprintf("/api/v0/services/%s/metadata/%s", serviceName, namespace) 21 | if req.URL.Path != u { 22 | t.Errorf("request URL should be %v but %v:", u, req.URL.Path) 23 | } 24 | 25 | if req.Method != "GET" { 26 | t.Error("request method should be GET but: ", req.Method) 27 | } 28 | 29 | respJSON := `{"type":12345,"region":"jp","env":"staging","instance_type":"c4.xlarge"}` 30 | res.Header()["Content-Type"] = []string{"application/json"} 31 | res.Header()["Last-Modified"] = []string{lastModified.Format(http.TimeFormat)} 32 | fmt.Fprint(res, respJSON) 33 | })) 34 | defer ts.Close() 35 | 36 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 37 | metadataResp, err := client.GetServiceMetaData(serviceName, namespace) 38 | if err != nil { 39 | t.Error("err should be nil but: ", err) 40 | } 41 | 42 | metadata := metadataResp.ServiceMetaData 43 | if metadata.(map[string]interface{})["type"].(float64) != 12345 { 44 | t.Errorf("got: %v, want: %v", metadata.(map[string]interface{})["type"], 12345) 45 | } 46 | if metadata.(map[string]interface{})["region"] != "jp" { 47 | t.Errorf("got: %v, want: %v", metadata.(map[string]interface{})["region"], "jp") 48 | } 49 | if metadata.(map[string]interface{})["env"] != "staging" { 50 | t.Errorf("got: %v, want: %v", metadata.(map[string]interface{})["env"], "staging") 51 | } 52 | if metadata.(map[string]interface{})["instance_type"] != "c4.xlarge" { 53 | t.Errorf("got: %v, want: %v", metadata.(map[string]interface{})["instance_type"], "c4.xlarge") 54 | } 55 | if !metadataResp.LastModified.Equal(lastModified) { 56 | t.Errorf("got: %v, want: %v", metadataResp.LastModified, lastModified) 57 | } 58 | } 59 | 60 | func TestGetServiceMetaDataNameSpaces(t *testing.T) { 61 | var ( 62 | serviceName = "testService" 63 | ) 64 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 65 | u := fmt.Sprintf("/api/v0/services/%s/metadata", serviceName) 66 | if req.URL.Path != u { 67 | t.Errorf("request URL should be %v but %v:", u, req.URL.Path) 68 | } 69 | 70 | if req.Method != "GET" { 71 | t.Error("request method should be GET but: ", req.Method) 72 | } 73 | 74 | respJSON := `{"metadata":[{"namespace":"testing1"}, {"namespace":"testing2"}]}` 75 | res.Header()["Content-Type"] = []string{"application/json"} 76 | fmt.Fprint(res, respJSON) 77 | })) 78 | defer ts.Close() 79 | 80 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 81 | namespaces, err := client.GetServiceMetaDataNameSpaces(serviceName) 82 | if err != nil { 83 | t.Error("err should be nil but: ", err) 84 | } 85 | 86 | if !reflect.DeepEqual(namespaces, []string{"testing1", "testing2"}) { 87 | t.Errorf("got: %v, want: %v", namespaces, []string{"testing1", "testing2"}) 88 | } 89 | } 90 | 91 | func TestPutServiceMetaData(t *testing.T) { 92 | var ( 93 | serviceName = "testService" 94 | namespace = "testing" 95 | ) 96 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 97 | u := fmt.Sprintf("/api/v0/services/%s/metadata/%s", serviceName, namespace) 98 | if req.URL.Path != u { 99 | t.Errorf("request URL should be %v but %v:", u, req.URL.Path) 100 | } 101 | 102 | if req.Method != "PUT" { 103 | t.Error("request method should be PUT but: ", req.Method) 104 | } 105 | 106 | body, _ := io.ReadAll(req.Body) 107 | reqJSON := `{"env":"staging","instance_type":"c4.xlarge","region":"jp","type":12345}` + "\n" 108 | if string(body) != reqJSON { 109 | t.Errorf("request body should be %v but %v", reqJSON, string(body)) 110 | } 111 | 112 | res.Header()["Content-Type"] = []string{"application/json"} 113 | fmt.Fprint(res, `{"success":true}`) 114 | })) 115 | defer ts.Close() 116 | 117 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 118 | metadata := map[string]interface{}{ 119 | "type": 12345, 120 | "region": "jp", 121 | "env": "staging", 122 | "instance_type": "c4.xlarge", 123 | } 124 | err := client.PutServiceMetaData(serviceName, namespace, &metadata) 125 | if err != nil { 126 | t.Error("err should be nil but: ", err) 127 | } 128 | } 129 | 130 | func TestDeleteServiceMetaData(t *testing.T) { 131 | var ( 132 | serviceName = "testService" 133 | namespace = "testing" 134 | ) 135 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 136 | u := fmt.Sprintf("/api/v0/services/%s/metadata/%s", serviceName, namespace) 137 | if req.URL.Path != u { 138 | t.Errorf("request URL should be %v but %v:", u, req.URL.Path) 139 | } 140 | 141 | if req.Method != "DELETE" { 142 | t.Error("request method should be DELETE but: ", req.Method) 143 | } 144 | 145 | res.Header()["Content-Type"] = []string{"application/json"} 146 | fmt.Fprint(res, `{"success":true}`) 147 | })) 148 | defer ts.Close() 149 | 150 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 151 | err := client.DeleteServiceMetaData(serviceName, namespace) 152 | if err != nil { 153 | t.Error("err should be nil but: ", err) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /services.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import "fmt" 4 | 5 | // Service represents Mackerel "service". 6 | type Service struct { 7 | Name string `json:"name"` 8 | Memo string `json:"memo"` 9 | Roles []string `json:"roles"` 10 | } 11 | 12 | // CreateServiceParam parameters for CreateService 13 | type CreateServiceParam struct { 14 | Name string `json:"name"` 15 | Memo string `json:"memo"` 16 | } 17 | 18 | // FindServices finds services. 19 | func (c *Client) FindServices() ([]*Service, error) { 20 | data, err := requestGet[struct { 21 | Services []*Service `json:"services"` 22 | }](c, "/api/v0/services") 23 | if err != nil { 24 | return nil, err 25 | } 26 | return data.Services, nil 27 | } 28 | 29 | // CreateService creates a service. 30 | func (c *Client) CreateService(param *CreateServiceParam) (*Service, error) { 31 | return requestPost[Service](c, "/api/v0/services", param) 32 | } 33 | 34 | // DeleteService deletes a service. 35 | func (c *Client) DeleteService(serviceName string) (*Service, error) { 36 | path := fmt.Sprintf("/api/v0/services/%s", serviceName) 37 | return requestDelete[Service](c, path) 38 | } 39 | 40 | // ListServiceMetricNames lists metric names of a service. 41 | func (c *Client) ListServiceMetricNames(serviceName string) ([]string, error) { 42 | data, err := requestGet[struct { 43 | Names []string `json:"names"` 44 | }](c, fmt.Sprintf("/api/v0/services/%s/metric-names", serviceName)) 45 | if err != nil { 46 | return nil, err 47 | } 48 | return data.Names, nil 49 | } 50 | 51 | // DeleteServiceGraphDef deletes a service metrics graph definition. 52 | func (c *Client) DeleteServiceGraphDef(serviceName string, graphName string) error { 53 | path := fmt.Sprintf("/api/v0/services/%s/graph-defs/%s", serviceName, graphName) 54 | _, err := requestDelete[any](c, path) 55 | return err 56 | } 57 | -------------------------------------------------------------------------------- /services_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestFindServices(t *testing.T) { 13 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 14 | if req.URL.Path != "/api/v0/services" { 15 | t.Error("request URL should be /api/v0/services but: ", req.URL.Path) 16 | } 17 | 18 | if req.Method != "GET" { 19 | t.Error("request method should be GET but: ", req.Method) 20 | } 21 | 22 | respJSON, _ := json.Marshal(map[string][]map[string]interface{}{ 23 | "services": { 24 | { 25 | "name": "My-Service", 26 | "memo": "hello", 27 | "roles": []string{"db-master", "db-slave"}, 28 | }, 29 | }, 30 | }) 31 | 32 | res.Header()["Content-Type"] = []string{"application/json"} 33 | fmt.Fprint(res, string(respJSON)) 34 | })) 35 | defer ts.Close() 36 | 37 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 38 | services, err := client.FindServices() 39 | 40 | if err != nil { 41 | t.Error("err should be nil but: ", err) 42 | } 43 | 44 | if services[0].Memo != "hello" { 45 | t.Error("request sends json including memo but: ", services[0]) 46 | } 47 | 48 | if reflect.DeepEqual(services[0].Roles, []string{"db-master", "db-slave"}) != true { 49 | t.Errorf("Wrong data for roles: %v", services[0].Roles) 50 | } 51 | 52 | } 53 | 54 | func TestCreateService(t *testing.T) { 55 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 56 | if req.URL.Path != "/api/v0/services" { 57 | t.Error("request URL should be /api/v0/services but: ", req.URL.Path) 58 | } 59 | 60 | if req.Method != "POST" { 61 | t.Error("request method should be POST but: ", req.Method) 62 | } 63 | 64 | respJSON, _ := json.Marshal(map[string]interface{}{ 65 | "name": "My-Service", 66 | "memo": "hello", 67 | "roles": []string{}, 68 | }) 69 | 70 | res.Header()["Content-Type"] = []string{"application/json"} 71 | fmt.Fprint(res, string(respJSON)) 72 | })) 73 | defer ts.Close() 74 | 75 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 76 | 77 | service, err := client.CreateService(&CreateServiceParam{ 78 | Name: "My-Service", 79 | Memo: "hello", 80 | }) 81 | 82 | if err != nil { 83 | t.Error("err should be nil but: ", err) 84 | } 85 | 86 | if service.Name != "My-Service" { 87 | t.Error("request sends json including name but: ", service.Name) 88 | } 89 | 90 | if service.Memo != "hello" { 91 | t.Error("request sends json including name but: ", service.Memo) 92 | } 93 | 94 | if len(service.Roles) != 0 { 95 | t.Error("request sends json including name but: ", service.Roles) 96 | } 97 | } 98 | 99 | func TestDeleteService(t *testing.T) { 100 | 101 | testName := "My-Service" 102 | 103 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 104 | if req.URL.Path != fmt.Sprintf("/api/v0/services/%s", testName) { 105 | t.Error("request URL should be /api/v0/services/ but: ", req.URL.Path) 106 | } 107 | 108 | if req.Method != "DELETE" { 109 | t.Error("request method should be DELETE but: ", req.Method) 110 | } 111 | 112 | respJSON, _ := json.Marshal(map[string]interface{}{ 113 | "name": "My-Service", 114 | "memo": "hello", 115 | "roles": []string{"ancient-role"}, 116 | }) 117 | 118 | res.Header()["Content-Type"] = []string{"application/json"} 119 | fmt.Fprint(res, string(respJSON)) 120 | })) 121 | defer ts.Close() 122 | 123 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 124 | 125 | service, err := client.DeleteService(testName) 126 | 127 | if err != nil { 128 | t.Error("err should be nil but: ", err) 129 | } 130 | 131 | if service.Name != "My-Service" { 132 | t.Error("request sends json including name but: ", service.Name) 133 | } 134 | 135 | if service.Memo != "hello" { 136 | t.Error("request sends json including name but: ", service.Memo) 137 | } 138 | 139 | if len(service.Roles) != 1 || service.Roles[0] != "ancient-role" { 140 | t.Error("request sends json including name but: ", service.Roles) 141 | } 142 | } 143 | 144 | func TestListServiceMetricNames(t *testing.T) { 145 | serviceName := "my-service" 146 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 147 | if req.URL.Path != fmt.Sprintf("/api/v0/services/%s/metric-names", serviceName) { 148 | t.Error("request URL should be /api/v0/services//metric-names but: ", req.URL.Path) 149 | } 150 | 151 | if req.Method != "GET" { 152 | t.Error("request method should be GET but: ", req.Method) 153 | } 154 | 155 | respJSON, _ := json.Marshal(map[string][]string{ 156 | "names": {"access.api", "access.web"}, 157 | }) 158 | 159 | res.Header()["Content-Type"] = []string{"application/json"} 160 | fmt.Fprint(res, string(respJSON)) 161 | })) 162 | defer ts.Close() 163 | 164 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 165 | names, err := client.ListServiceMetricNames(serviceName) 166 | 167 | if err != nil { 168 | t.Error("err should be nil but: ", err) 169 | } 170 | 171 | if reflect.DeepEqual(names, []string{"access.api", "access.web"}) != true { 172 | t.Errorf("Wrong data for metric names: %v", names) 173 | } 174 | } 175 | 176 | func TestDeleteServiceGraphDef(t *testing.T) { 177 | serviceName := "my-service" 178 | graphName := "graph-name.*" 179 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 180 | if req.URL.Path != fmt.Sprintf("/api/v0/services/%s/graph-defs/%s", serviceName, graphName) { 181 | t.Error("request URL should be /api/v0/services//graph-defs/ but: ", req.URL.Path) 182 | } 183 | 184 | if req.Method != "DELETE" { 185 | t.Error("request method should be DELETE but: ", req.Method) 186 | } 187 | 188 | respJSON, _ := json.Marshal(map[string]bool{"success": true}) 189 | 190 | res.Header()["Content-Type"] = []string{"application/json"} 191 | fmt.Fprint(res, string(respJSON)) 192 | })) 193 | defer ts.Close() 194 | 195 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 196 | err := client.DeleteServiceGraphDef(serviceName, graphName) 197 | 198 | if err != nil { 199 | t.Error("err should be nil but: ", err) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /users.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import "fmt" 4 | 5 | // User information 6 | type User struct { 7 | ID string `json:"id,omitempty"` 8 | ScreenName string `json:"screenName,omitempty"` 9 | Email string `json:"email,omitempty"` 10 | Authority string `json:"authority,omitempty"` 11 | 12 | IsInRegistrationProcess bool `json:"isInRegistrationProcess,omitempty"` 13 | IsMFAEnabled bool `json:"isMFAEnabled,omitempty"` 14 | AuthenticationMethods []string `json:"authenticationMethods,omitempty"` 15 | JoinedAt int64 `json:"joinedAt,omitempty"` 16 | } 17 | 18 | // FindUsers finds users. 19 | func (c *Client) FindUsers() ([]*User, error) { 20 | data, err := requestGet[struct { 21 | Users []*User `json:"users"` 22 | }](c, "/api/v0/users") 23 | if err != nil { 24 | return nil, err 25 | } 26 | return data.Users, nil 27 | } 28 | 29 | // DeleteUser deletes a user. 30 | func (c *Client) DeleteUser(userID string) (*User, error) { 31 | path := fmt.Sprintf("/api/v0/users/%s", userID) 32 | return requestDelete[User](c, path) 33 | } 34 | -------------------------------------------------------------------------------- /users_test.go: -------------------------------------------------------------------------------- 1 | package mackerel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestFindUsers(t *testing.T) { 13 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 14 | if req.URL.Path != "/api/v0/users" { 15 | t.Error("request URL should be but: ", req.URL.Path) 16 | } 17 | 18 | if req.Method != "GET" { 19 | t.Error("request method should be GET but: ", req.Method) 20 | } 21 | 22 | respJSON, _ := json.Marshal(map[string][]map[string]interface{}{ 23 | "users": { 24 | { 25 | "id": "ABCDEFGHIJK", 26 | "screenName": "myname", 27 | "email": "test@example.com", 28 | "authority": "viewer", 29 | "isInRegistrationProcess": true, 30 | "isMFAEnabled": true, 31 | "authenticationMethods": []string{"password"}, 32 | "joinedAt": 1560000000, 33 | }, 34 | }, 35 | }) 36 | res.Header()["Content-Type"] = []string{"application/json"} 37 | fmt.Fprint(res, string(respJSON)) 38 | })) 39 | defer ts.Close() 40 | 41 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 42 | users, err := client.FindUsers() 43 | 44 | if err != nil { 45 | t.Error("err should be nil but: ", err) 46 | } 47 | 48 | if users[0].ID != "ABCDEFGHIJK" { 49 | t.Error("request sends json including id but: ", users[0].ID) 50 | } 51 | 52 | if users[0].ScreenName != "myname" { 53 | t.Error("request sends json including screenName but: ", users[0].ScreenName) 54 | } 55 | 56 | if users[0].Email != "test@example.com" { 57 | t.Error("request sends json including email but: ", users[0].Email) 58 | } 59 | 60 | if users[0].Authority != "viewer" { 61 | t.Error("request sends json including authority but: ", users[0].Authority) 62 | } 63 | 64 | if users[0].IsInRegistrationProcess != true { 65 | t.Error("request sends json including isInRegistrationProcess but: ", users[0].IsInRegistrationProcess) 66 | } 67 | 68 | if users[0].IsMFAEnabled != true { 69 | t.Error("request sends json including isMFAEnabled but: ", users[0].IsMFAEnabled) 70 | } 71 | 72 | if reflect.DeepEqual(users[0].AuthenticationMethods, []string{"password"}) != true { 73 | t.Errorf("Wrong data for users: %v", users[0].AuthenticationMethods) 74 | } 75 | 76 | if users[0].JoinedAt != 1560000000 { 77 | t.Error("request sends json including joinedAt but: ", users[0].JoinedAt) 78 | } 79 | } 80 | 81 | func TestDeleteUser(t *testing.T) { 82 | 83 | testUserID := "ABCDEFGHIJK" 84 | 85 | ts := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 86 | if req.URL.Path != fmt.Sprintf("/api/v0/users/%s", testUserID) { 87 | t.Error("request URL should be /api/v0/users/ but: ", req.URL.Path) 88 | } 89 | 90 | if req.Method != "DELETE" { 91 | t.Error("request method should be DELETE but: ", req.Method) 92 | } 93 | 94 | respJSON, _ := json.Marshal(map[string]interface{}{ 95 | "id": "ABCDEFGHIJK", 96 | "screenName": "myname", 97 | "email": "test@example.com", 98 | "authority": "viewer", 99 | "isInRegistrationProcess": true, 100 | "isMFAEnabled": true, 101 | "authenticationMethods": []string{"password"}, 102 | "joinedAt": 1560000000, 103 | }) 104 | 105 | res.Header()["Content-Type"] = []string{"application/json"} 106 | fmt.Fprint(res, string(respJSON)) 107 | })) 108 | defer ts.Close() 109 | 110 | client, _ := NewClientWithOptions("dummy-key", ts.URL, false) 111 | user, err := client.DeleteUser(testUserID) 112 | 113 | if err != nil { 114 | t.Error("err should be nil but: ", err) 115 | } 116 | 117 | if user.ID != "ABCDEFGHIJK" { 118 | t.Error("request sends json including id but: ", user.ID) 119 | } 120 | 121 | if user.ScreenName != "myname" { 122 | t.Error("request sends json including screenName but: ", user.ScreenName) 123 | } 124 | 125 | if user.Email != "test@example.com" { 126 | t.Error("request sends json including email but: ", user.Email) 127 | } 128 | 129 | if user.Authority != "viewer" { 130 | t.Error("request sends json including authority but: ", user.Authority) 131 | } 132 | 133 | if user.IsInRegistrationProcess != true { 134 | t.Error("request sends json including isInRegistrationProcess but: ", user.IsInRegistrationProcess) 135 | } 136 | 137 | if user.IsMFAEnabled != true { 138 | t.Error("request sends json including isMFAEnabled but: ", user.IsMFAEnabled) 139 | } 140 | 141 | if reflect.DeepEqual(user.AuthenticationMethods, []string{"password"}) != true { 142 | t.Errorf("Wrong data for users: %v", user.AuthenticationMethods) 143 | } 144 | 145 | if user.JoinedAt != 1560000000 { 146 | t.Error("request sends json including joinedAt but: ", user.JoinedAt) 147 | } 148 | } 149 | --------------------------------------------------------------------------------