blob: 94546c62c419e8890a839063c84cc398cbe263a2 [file]
// 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 view fetches the asz.view document of an AI agent conversation from the OAP's
// GET /ai-agent/conversations/{conversation}/v1/view route. The route lives on the query
// host beside /graphql, not on the admin host, because the document is what the UI reads;
// it is streamed, since a long conversation renders to tens of megabytes, so the body is
// copied through and never held whole.
package view
import (
"context"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"strings"
"github.com/apache/skywalking-cli/pkg/contextkey"
"github.com/apache/skywalking-cli/pkg/transport"
)
const (
// MediaTypeJSON names the document, its version a parameter: the route's default body.
MediaTypeJSON = "application/vnd.skywalking.asz.view+json"
// MediaTypeYAML is the same document as YAML, chosen by Accept.
MediaTypeYAML = "application/vnd.skywalking.asz.view+yaml"
// problemType is the route's error body, RFC 9457.
problemType = "application/problem+json"
defaultBaseURL = "http://127.0.0.1:12800/graphql"
)
// Problem is the route's error: an RFC 9457 problem document carrying the status.
type Problem struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail"`
URL string `json:"-"`
}
func (p *Problem) Error() string {
if p.Detail != "" {
return fmt.Sprintf("%d %s: %s (%s)", p.Status, p.Title, p.Detail, p.URL)
}
return fmt.Sprintf("%d %s (%s)", p.Status, p.Title, p.URL)
}
// CoreURL is the root of the query host the route lives on, derived from the GraphQL
// base URL by dropping its path: http://host:12800/graphql becomes http://host:12800.
// A base URL that does not parse is returned trimmed, so the error surfaces on the call.
func CoreURL(baseURL string) string {
trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/")
u, err := url.Parse(trimmed)
if err != nil || u.Host == "" {
return trimmed
}
return u.Scheme + "://" + u.Host
}
// Path is the route of one conversation.
func Path(conversation string) string {
return "/ai-agent/conversations/" + url.PathEscape(conversation) + "/v1/view"
}
// Fetch streams the document of the conversation to out and returns the Content-Type
// it came with. serviceName is required; instanceName narrows the read to one sender.
// A non-2xx answer is returned as a *Problem when the OAP sent one.
func Fetch(ctx context.Context, conversation, serviceName, instanceName string, yaml bool, out io.Writer) (string, error) {
query := url.Values{"service": {serviceName}}
if instanceName != "" {
query.Set("instance", instanceName)
}
full := CoreURL(transport.GetValue(ctx, contextkey.BaseURL{}, defaultBaseURL)) + Path(conversation) + "?" + query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, http.NoBody)
if err != nil {
return "", err
}
if yaml {
req.Header.Set("Accept", MediaTypeYAML)
} else {
req.Header.Set("Accept", MediaTypeJSON)
}
if authorization := transport.AuthHeader(ctx); authorization != "" {
req.Header.Set("Authorization", authorization)
}
resp, err := transport.HTTPClient(ctx).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return contentType, readError(resp, full)
}
_, err = io.Copy(out, resp.Body)
return contentType, err
}
// readError turns a non-2xx response into an error: the problem document when the OAP
// sent one, otherwise the status and whatever the body says.
func readError(resp *http.Response, full string) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if mediaType == problemType {
problem := &Problem{URL: full}
if json.Unmarshal(body, problem) == nil && problem.Status != 0 {
return problem
}
}
return fmt.Errorf("%s: %s: %s", full, resp.Status, strings.TrimSpace(string(body)))
}