client.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. package wikijs
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "log/slog"
  10. "net/http"
  11. "net/url"
  12. "strings"
  13. "time"
  14. )
  15. // maxResponseBytes bounds the response body read from WikiJS.
  16. const maxResponseBytes = 32 << 20
  17. // Client talks to the WikiJS GraphQL API.
  18. type Client struct {
  19. baseURL string
  20. endpoint string
  21. apiKey string
  22. userAgent string
  23. http *http.Client
  24. logger *slog.Logger
  25. }
  26. // Option customizes a [Client].
  27. type Option func(*Client)
  28. // WithHTTPClient sets the HTTP client used for GraphQL requests.
  29. func WithHTTPClient(hc *http.Client) Option {
  30. return func(c *Client) {
  31. if hc != nil {
  32. c.http = hc
  33. }
  34. }
  35. }
  36. // WithLogger enables debug logging of GraphQL operations.
  37. func WithLogger(logger *slog.Logger) Option {
  38. return func(c *Client) {
  39. if logger != nil {
  40. c.logger = logger
  41. }
  42. }
  43. }
  44. // WithUserAgent overrides the User-Agent sent to WikiJS.
  45. func WithUserAgent(userAgent string) Option {
  46. return func(c *Client) {
  47. if userAgent != "" {
  48. c.userAgent = userAgent
  49. }
  50. }
  51. }
  52. // New creates a client for a WikiJS base URL. The URL may end with /graphql;
  53. // it is normalized so that the GraphQL endpoint is always addressed once.
  54. func New(rawBaseURL, apiKey string, opts ...Option) (*Client, error) {
  55. base := strings.TrimRight(strings.TrimSpace(rawBaseURL), "/")
  56. u, err := url.Parse(base)
  57. if err != nil {
  58. return nil, fmt.Errorf("parse WikiJS URL: %w", err)
  59. }
  60. if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
  61. return nil, fmt.Errorf("WikiJS URL must be an absolute http(s) URL, got %q", rawBaseURL)
  62. }
  63. if u.RawQuery != "" || u.Fragment != "" {
  64. return nil, fmt.Errorf("WikiJS URL must not contain a query or fragment, got %q", rawBaseURL)
  65. }
  66. endpoint := base
  67. if strings.HasSuffix(base, "/graphql") {
  68. base = strings.TrimSuffix(base, "/graphql")
  69. } else {
  70. endpoint += "/graphql"
  71. }
  72. c := &Client{
  73. baseURL: base,
  74. endpoint: endpoint,
  75. apiKey: apiKey,
  76. userAgent: "wikijs-mcp",
  77. http: http.DefaultClient,
  78. logger: slog.New(slog.DiscardHandler),
  79. }
  80. for _, opt := range opts {
  81. opt(c)
  82. }
  83. return c, nil
  84. }
  85. // SearchPages runs a WikiJS full-text search. path and locale are optional.
  86. func (c *Client) SearchPages(ctx context.Context, query, path, locale string) (SearchResults, error) {
  87. vars := map[string]any{"query": query}
  88. putString(vars, "path", path)
  89. putString(vars, "locale", locale)
  90. var data struct {
  91. Pages *struct {
  92. Search *struct {
  93. Results []*SearchHit `json:"results"`
  94. Suggestions []string `json:"suggestions"`
  95. TotalHits int `json:"totalHits"`
  96. } `json:"search"`
  97. } `json:"pages"`
  98. }
  99. if err := c.do(ctx, "SearchPages", searchPagesQuery, vars, &data); err != nil {
  100. return SearchResults{}, err
  101. }
  102. out := SearchResults{Results: []SearchHit{}, Suggestions: []string{}}
  103. if data.Pages == nil || data.Pages.Search == nil {
  104. return out, nil
  105. }
  106. for _, hit := range data.Pages.Search.Results {
  107. if hit == nil {
  108. continue
  109. }
  110. hit.URL = c.pageURL(hit.Path, hit.Locale)
  111. out.Results = append(out.Results, *hit)
  112. }
  113. out.Suggestions = append(out.Suggestions, data.Pages.Search.Suggestions...)
  114. out.TotalHits = data.Pages.Search.TotalHits
  115. return out, nil
  116. }
  117. // PageByID returns a page by its numeric ID. It returns a nil page without an
  118. // error when WikiJS does not know the ID.
  119. func (c *Client) PageByID(ctx context.Context, id int) (*Page, error) {
  120. var data struct {
  121. Pages *struct {
  122. Single *Page `json:"single"`
  123. } `json:"pages"`
  124. }
  125. if err := c.do(ctx, "GetPageById", pageByIDQuery, map[string]any{"id": id}, &data); err != nil {
  126. return nil, err
  127. }
  128. if data.Pages == nil || data.Pages.Single == nil {
  129. return nil, nil
  130. }
  131. return c.decorate(data.Pages.Single), nil
  132. }
  133. // PageByPath returns a page by its path and locale. It returns a nil page
  134. // without an error when the page does not exist.
  135. func (c *Client) PageByPath(ctx context.Context, path, locale string) (*Page, error) {
  136. vars := map[string]any{"path": path, "locale": locale}
  137. var data struct {
  138. Pages *struct {
  139. Single *Page `json:"singleByPath"`
  140. } `json:"pages"`
  141. }
  142. if err := c.do(ctx, "GetPageByPath", pageByPathQuery, vars, &data); err != nil {
  143. return nil, err
  144. }
  145. if data.Pages == nil || data.Pages.Single == nil {
  146. return nil, nil
  147. }
  148. return c.decorate(data.Pages.Single), nil
  149. }
  150. // ListPages returns pages, optionally filtered by limit, locale, and tags.
  151. func (c *Client) ListPages(ctx context.Context, opts ListOptions) ([]PageListItem, error) {
  152. vars := map[string]any{}
  153. if opts.Limit != nil {
  154. vars["limit"] = *opts.Limit
  155. }
  156. putString(vars, "locale", opts.Locale)
  157. if len(opts.Tags) > 0 {
  158. vars["tags"] = opts.Tags
  159. }
  160. var data struct {
  161. Pages *struct {
  162. List []*PageListItem `json:"list"`
  163. } `json:"pages"`
  164. }
  165. if err := c.do(ctx, "GetAllPages", listPagesQuery, vars, &data); err != nil {
  166. return nil, err
  167. }
  168. out := []PageListItem{}
  169. if data.Pages == nil {
  170. return out, nil
  171. }
  172. for _, page := range data.Pages.List {
  173. if page == nil {
  174. continue
  175. }
  176. page.URL = c.pageURL(page.Path, page.Locale)
  177. if page.Tags == nil {
  178. page.Tags = []string{}
  179. }
  180. out = append(out, *page)
  181. }
  182. return out, nil
  183. }
  184. // CreatePage creates a page.
  185. func (c *Client) CreatePage(ctx context.Context, in CreatePageInput) (MutationResult, error) {
  186. vars := map[string]any{
  187. "title": in.Title,
  188. "content": in.Content,
  189. "description": in.Description,
  190. "editor": in.Editor,
  191. "isPublished": in.IsPublished,
  192. "isPrivate": in.IsPrivate,
  193. "locale": in.Locale,
  194. "tags": nonNilStrings(in.Tags),
  195. "path": in.Path,
  196. }
  197. var data struct {
  198. Pages *struct {
  199. Create *struct {
  200. ResponseResult *responseResult `json:"responseResult"`
  201. } `json:"create"`
  202. } `json:"pages"`
  203. }
  204. if err := c.do(ctx, "CreatePage", createPageMutation, vars, &data); err != nil {
  205. return MutationResult{}, err
  206. }
  207. if data.Pages == nil || data.Pages.Create == nil {
  208. return MutationResult{}, errors.New("wikijs CreatePage: missing create result")
  209. }
  210. return mutationResult(data.Pages.Create.ResponseResult)
  211. }
  212. // UpdatePage updates a page.
  213. func (c *Client) UpdatePage(ctx context.Context, in UpdatePageInput) (MutationResult, error) {
  214. vars := map[string]any{
  215. "id": in.ID,
  216. "title": in.Title,
  217. "content": in.Content,
  218. "description": in.Description,
  219. "editor": in.Editor,
  220. "isPublished": in.IsPublished,
  221. "isPrivate": in.IsPrivate,
  222. "locale": in.Locale,
  223. "tags": nonNilStrings(in.Tags),
  224. "path": in.Path,
  225. }
  226. var data struct {
  227. Pages *struct {
  228. Update *struct {
  229. ResponseResult *responseResult `json:"responseResult"`
  230. } `json:"update"`
  231. } `json:"pages"`
  232. }
  233. if err := c.do(ctx, "UpdatePage", updatePageMutation, vars, &data); err != nil {
  234. return MutationResult{}, err
  235. }
  236. if data.Pages == nil || data.Pages.Update == nil {
  237. return MutationResult{}, errors.New("wikijs UpdatePage: missing update result")
  238. }
  239. return mutationResult(data.Pages.Update.ResponseResult)
  240. }
  241. type responseResult struct {
  242. Succeeded bool `json:"succeeded"`
  243. ErrorCode int `json:"errorCode"`
  244. Slug string `json:"slug"`
  245. Message string `json:"message"`
  246. }
  247. func mutationResult(rr *responseResult) (MutationResult, error) {
  248. if rr == nil {
  249. return MutationResult{}, errors.New("wikijs mutation: missing responseResult")
  250. }
  251. return MutationResult{
  252. Succeeded: rr.Succeeded,
  253. Message: rr.Message,
  254. ErrorCode: rr.ErrorCode,
  255. Slug: rr.Slug,
  256. }, nil
  257. }
  258. type graphqlRequest struct {
  259. Query string `json:"query"`
  260. Variables map[string]any `json:"variables,omitempty"`
  261. }
  262. type graphqlError struct {
  263. Message string `json:"message"`
  264. }
  265. type graphqlResponse struct {
  266. Data json.RawMessage `json:"data"`
  267. Errors []graphqlError `json:"errors"`
  268. }
  269. // APIError describes a failed WikiJS request.
  270. type APIError struct {
  271. Operation string
  272. Status int
  273. Messages []string
  274. Body string
  275. Err error
  276. }
  277. func (e *APIError) Error() string {
  278. var b strings.Builder
  279. b.WriteString("wikijs")
  280. if e.Operation != "" {
  281. b.WriteByte(' ')
  282. b.WriteString(e.Operation)
  283. }
  284. b.WriteString(": ")
  285. switch {
  286. case len(e.Messages) > 0:
  287. b.WriteString(strings.Join(e.Messages, "; "))
  288. case e.Body != "":
  289. b.WriteString(e.Body)
  290. case e.Err != nil:
  291. b.WriteString(e.Err.Error())
  292. default:
  293. b.WriteString("request failed")
  294. }
  295. if e.Status != 0 {
  296. fmt.Fprintf(&b, " (HTTP %d)", e.Status)
  297. }
  298. return b.String()
  299. }
  300. func (e *APIError) Unwrap() error { return e.Err }
  301. func (c *Client) do(ctx context.Context, operation, query string, vars map[string]any, out any) error {
  302. payload, err := json.Marshal(graphqlRequest{Query: query, Variables: vars})
  303. if err != nil {
  304. return fmt.Errorf("wikijs %s: encode request: %w", operation, err)
  305. }
  306. req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(payload))
  307. if err != nil {
  308. return fmt.Errorf("wikijs %s: build request: %w", operation, err)
  309. }
  310. req.Header.Set("Content-Type", "application/json")
  311. req.Header.Set("Accept", "application/json")
  312. req.Header.Set("Authorization", "Bearer "+c.apiKey)
  313. req.Header.Set("User-Agent", c.userAgent)
  314. start := time.Now()
  315. resp, err := c.http.Do(req)
  316. if err != nil {
  317. return fmt.Errorf("wikijs %s: %w", operation, err)
  318. }
  319. defer resp.Body.Close()
  320. body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
  321. if err != nil {
  322. return fmt.Errorf("wikijs %s: read response: %w", operation, err)
  323. }
  324. if len(body) > maxResponseBytes {
  325. return fmt.Errorf("wikijs %s: response exceeds %d bytes", operation, maxResponseBytes)
  326. }
  327. if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
  328. return &APIError{
  329. Operation: operation,
  330. Status: resp.StatusCode,
  331. Body: truncate(strings.TrimSpace(string(body)), 512),
  332. }
  333. }
  334. var envelope graphqlResponse
  335. if err := json.Unmarshal(body, &envelope); err != nil {
  336. return &APIError{Operation: operation, Err: fmt.Errorf("decode response: %w", err)}
  337. }
  338. if len(envelope.Errors) > 0 {
  339. messages := make([]string, 0, len(envelope.Errors))
  340. for _, e := range envelope.Errors {
  341. messages = append(messages, e.Message)
  342. }
  343. return &APIError{Operation: operation, Messages: messages}
  344. }
  345. if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
  346. return &APIError{Operation: operation, Messages: []string{"response contains no data"}}
  347. }
  348. if err := json.Unmarshal(envelope.Data, out); err != nil {
  349. return fmt.Errorf("wikijs %s: decode data: %w", operation, err)
  350. }
  351. c.logger.Debug("wikijs request",
  352. "operation", operation,
  353. "duration", time.Since(start),
  354. "status", resp.StatusCode,
  355. )
  356. return nil
  357. }
  358. func (c *Client) decorate(page *Page) *Page {
  359. page.URL = c.pageURL(page.Path, page.Locale)
  360. if page.Tags == nil {
  361. page.Tags = []Tag{}
  362. }
  363. return page
  364. }
  365. func (c *Client) pageURL(path, locale string) string {
  366. p := strings.TrimLeft(path, "/")
  367. if locale != "" && locale != "en" {
  368. return c.baseURL + "/" + locale + "/" + p
  369. }
  370. return c.baseURL + "/" + p
  371. }
  372. func putString(vars map[string]any, key, value string) {
  373. if value != "" {
  374. vars[key] = value
  375. }
  376. }
  377. func nonNilStrings(values []string) []string {
  378. if values == nil {
  379. return []string{}
  380. }
  381. return values
  382. }
  383. func truncate(s string, max int) string {
  384. if len(s) <= max {
  385. return s
  386. }
  387. return s[:max] + "..."
  388. }