| // Licensed to Apache Software Foundation (ASF) under one or more contributor |
| // license agreements. See the NOTICE file distributed with |
| // this work for additional information regarding copyright |
| // ownership. Apache Software Foundation (ASF) licenses this file to you 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 |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // 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. |
| |
| // Package client is the REST client for the OAP admin-server (default port 17128), |
| // the HTTP host that bundles the status, inspect, ui-management, dsl-debugging and |
| // runtime-rule feature modules. It is the REST counterpart of pkg/graphql/client |
| // and shares its TLS / basic-auth handling via pkg/transport. |
| package client |
| |
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "io" |
| "net" |
| "net/http" |
| "net/url" |
| "strings" |
| "time" |
| |
| "github.com/apache/skywalking-cli/pkg/contextkey" |
| "github.com/apache/skywalking-cli/pkg/transport" |
| ) |
| |
| const ( |
| // DefaultAdminURL is the fallback admin-server REST base URL. |
| DefaultAdminURL = "http://127.0.0.1:17128" |
| // DefaultAdminPort is the admin-server REST port (admin-server `port`). |
| DefaultAdminPort = "17128" |
| |
| defaultTimeout = 30 * time.Second |
| |
| contentTypeJSON = "application/json" |
| ) |
| |
| // DeriveAdminURL resolves the admin REST base URL. When adminURL is non-empty it is |
| // used verbatim (whitespace and trailing slash trimmed). Otherwise the admin URL is |
| // derived from the GraphQL base URL by reusing its scheme and host and swapping in the |
| // admin port, dropping any path such as `/graphql`. A non-parseable base URL falls |
| // back to DefaultAdminURL. |
| func DeriveAdminURL(baseURL, adminURL string) string { |
| if s := strings.TrimRight(strings.TrimSpace(adminURL), "/"); s != "" { |
| return s |
| } |
| u, err := url.Parse(strings.TrimSpace(baseURL)) |
| if err != nil || u.Hostname() == "" { |
| return DefaultAdminURL |
| } |
| scheme := u.Scheme |
| if scheme == "" { |
| scheme = "http" |
| } |
| // net.JoinHostPort brackets IPv6 hosts, e.g. http://[::1]:17128. |
| return scheme + "://" + net.JoinHostPort(u.Hostname(), DefaultAdminPort) |
| } |
| |
| // BaseURL returns the admin REST base URL stored in the context (set by the |
| // interceptor), trailing slash trimmed, falling back to DefaultAdminURL. |
| func BaseURL(ctx context.Context) string { |
| return strings.TrimRight(transport.GetValue(ctx, contextkey.AdminURL{}, DefaultAdminURL), "/") |
| } |
| |
| // Request describes a single admin REST call. |
| type Request struct { |
| Method string |
| Path string // appended to the admin base URL, e.g. "/status/cluster/nodes" |
| Query url.Values |
| Body io.Reader |
| ContentType string // request Content-Type, when a body is sent |
| Accept string // Accept header; defaults to application/json |
| Headers map[string]string // extra request headers (e.g. If-None-Match) |
| } |
| |
| // Response is the raw result of an admin REST call, exposed so callers can read |
| // status-dependent headers (e.g. runtime-rule X-Sw-* / ETag) and bodies (tar.gz). |
| type Response struct { |
| StatusCode int |
| Header http.Header |
| Body []byte |
| URL string |
| } |
| |
| // IsSuccess reports whether the response carries a 2xx status code. |
| func (r *Response) IsSuccess() bool { |
| return r.StatusCode >= 200 && r.StatusCode < 300 |
| } |
| |
| // APIError is returned for non-2xx admin responses. It decodes the common admin |
| // error envelopes so callers can switch on the semantic Status code (e.g. |
| // requires_inactivate_first, session_not_found, cluster_view_split). |
| type APIError struct { |
| StatusCode int |
| URL string |
| Status string // applyStatus / code / status from the JSON envelope |
| Message string |
| Body string |
| } |
| |
| func (e *APIError) Error() string { |
| msg := e.Message |
| if msg == "" { |
| msg = e.Body |
| } |
| if e.Status != "" { |
| return fmt.Sprintf("admin API %s: HTTP %d (%s): %s", e.URL, e.StatusCode, e.Status, msg) |
| } |
| return fmt.Sprintf("admin API %s: HTTP %d: %s", e.URL, e.StatusCode, msg) |
| } |
| |
| // ParseError builds an APIError from a non-2xx response, decoding the admin error |
| // envelopes: {applyStatus,message} (runtime-rule), {status,code,message} |
| // (dsl-debugging / oal) and {error} (inspect). A non-JSON body is kept as raw text. |
| func ParseError(resp *Response) *APIError { |
| e := &APIError{StatusCode: resp.StatusCode, URL: resp.URL, Body: strings.TrimSpace(string(resp.Body))} |
| var env struct { |
| ApplyStatus string `json:"applyStatus"` |
| Status string `json:"status"` |
| Code string `json:"code"` |
| Message string `json:"message"` |
| Error string `json:"error"` |
| } |
| if json.Unmarshal(resp.Body, &env) == nil { |
| switch { |
| case env.ApplyStatus != "": |
| e.Status = env.ApplyStatus |
| case env.Code != "": |
| e.Status = env.Code |
| case env.Status != "": |
| e.Status = env.Status |
| } |
| switch { |
| case env.Message != "": |
| e.Message = env.Message |
| case env.Error != "": |
| e.Message = env.Error |
| } |
| } |
| return e |
| } |
| |
| // Do performs req against the admin base URL and returns the raw Response for any |
| // HTTP status. Only transport-level failures are returned as an error; callers |
| // decide how to treat non-2xx (see Response.IsSuccess / ParseError). It reuses the |
| // shared TLS and basic-auth handling from pkg/transport. |
| func Do(ctx context.Context, req *Request) (*Response, error) { |
| full := BaseURL(ctx) + req.Path |
| if len(req.Query) > 0 { |
| full += "?" + req.Query.Encode() |
| } |
| |
| httpReq, err := http.NewRequestWithContext(ctx, req.Method, full, req.Body) |
| if err != nil { |
| return nil, err |
| } |
| accept := req.Accept |
| if accept == "" { |
| accept = contentTypeJSON |
| } |
| httpReq.Header.Set("Accept", accept) |
| if req.ContentType != "" { |
| httpReq.Header.Set("Content-Type", req.ContentType) |
| } |
| if auth := transport.AuthHeader(ctx); auth != "" { |
| httpReq.Header.Set("Authorization", auth) |
| } |
| for k, v := range req.Headers { |
| httpReq.Header.Set(k, v) |
| } |
| |
| httpClient := transport.HTTPClient(ctx) |
| httpClient.Timeout = defaultTimeout |
| resp, err := httpClient.Do(httpReq) |
| if err != nil { |
| return nil, err |
| } |
| defer resp.Body.Close() |
| |
| body, err := io.ReadAll(resp.Body) |
| if err != nil { |
| return nil, err |
| } |
| return &Response{StatusCode: resp.StatusCode, Header: resp.Header, Body: body, URL: full}, nil |
| } |
| |
| // GetJSON issues a GET to path and decodes a JSON 2xx response into out (which may be |
| // nil to discard the body). A non-2xx response is returned as an *APIError. |
| func GetJSON(ctx context.Context, path string, query url.Values, out any) error { |
| resp, err := Do(ctx, &Request{Method: http.MethodGet, Path: path, Query: query}) |
| if err != nil { |
| return err |
| } |
| if !resp.IsSuccess() { |
| return ParseError(resp) |
| } |
| if out == nil || len(resp.Body) == 0 { |
| return nil |
| } |
| return json.Unmarshal(resp.Body, out) |
| } |
| |
| // SendJSON issues method to path with an optional JSON body and decodes a JSON 2xx |
| // response into out (which may be nil). A non-2xx response is returned as an *APIError. |
| func SendJSON(ctx context.Context, method, path string, query url.Values, in, out any) error { |
| req := &Request{Method: method, Path: path, Query: query} |
| if in != nil { |
| data, err := json.Marshal(in) |
| if err != nil { |
| return err |
| } |
| req.Body = strings.NewReader(string(data)) |
| req.ContentType = contentTypeJSON |
| } |
| resp, err := Do(ctx, req) |
| if err != nil { |
| return err |
| } |
| if !resp.IsSuccess() { |
| return ParseError(resp) |
| } |
| if out == nil || len(resp.Body) == 0 { |
| return nil |
| } |
| return json.Unmarshal(resp.Body, out) |
| } |