Confluence API로 가져온 제목으로 org-mode 마크업 링크 삽입 - org-cliplink 기능 확장

5 minute read

org-mode에 웹 링크를 삽입할 일이 있으면 Org-cliplink를 사용하고 있다. 인증이 필요한 Confluence 페이지는 org-cliplink로 가져올 수 없어서 기능 추가를 했다.

Confluence API 테스트

Emacs lisp 코드를 짜기 전에 API 테스트를 해서 원하는 걸 가져올 수 있는지 테스트해 본다. 추출하고 싶은 항목은 title, space name이다. Confluence 버전이 6.x라서 HTTP 기본 인증만 지원한다.

Restclient를 사용하면 org 문서에서 바로 테스트할 수 있다.

#+begin_src restclient
  :auth := (format "Basic %s" (base64url-encode-string (format "%s:%s" "ohyecloudy" "SUPERSECRET")))
  GET http://confluence/rest/api/content/1234
  Authorization: :auth
#+end_src

user:password 스트링을 base64로 인코딩한 문자열 앞에 Basic 을 붙여주면 된다. 소스 블럭에서 C-c C-c 키를 누르면 결과가 ’짠’하고 나타난다.

#+RESULTS:
#+BEGIN_SRC js
  {
      ...
      "title": "Confluence Page Title",
      "space": {
          "name": "MySpace",
          ...
      },
      ...
  }
#+END_SRC

rest/api/content/[ID] 메서드를 사용하면 URL 링크를 삽입할 때, 사용할 스페이스 이름과 페이지 제목을 가져올 수 있다.

auth-source에 id와 password를 저장

Auth-source 파일에 id와 password를 저장한다. 이후에 이 정보로 HTTP Basic Authentication에 사용할 문자열을 만들 예정이다.

my/org-cliplink-confluence--id-password 함수는 id password 리스트를 리턴한다. 찾지 못하면 nil 을 리턴한다.

(defun my/org-cliplink-confluence--id-password (host)
  (when-let* ((found (auth-source-search
                     :host host
                     :requires '(:secret)))
              (first-found (nth 0 found))
              (id (plist-get first-found :user))
              (password (funcall (plist-get first-found :secret))))
    (list id password)))

~/.authinfo.gpg~/.authinfo 파일에 id와 password를 추가한다.

machine confluence_host login ohyecloudy password SUPERSECRET

confluence_host 를 인자로 넘겨서 리턴 값을 확인한다.

(my/org-cliplink-confluence--id-password "confluence_host")
("ohyecloudy" "SUPERSECRET")

HTTP Basic Authentication 문자열 생성

Confluence API를 호출할 때, 실제 필요한 문자열이다. id:password 문자열을 Base64URL로 인코딩한다.

(defun my/org-cliplink-confluence--auth (host)
  (when-let* ((id-password (my/org-cliplink-confluence--id-password host))
              (encoded (base64url-encode-string
                        (format "%s:%s" (car id-password)
                                (cadr id-password)))))
    `("Authorization" . ,(format "Basic %s" encoded))))

결과는 ("Authorization" . "Basic ENCODED_ID_PASSWORD") 형식의 리스트다. url-request 패키지에서 해더로 Dotted Pair Notation 리스트를 받기 때문에 해당 포멧을 리턴한다.

Confluence API를 사용해 스페이스 이름과 페이지 제목을 가져오기

만든 HTTP 기본 인증을 사용해 Confluence API를 호출한다.

(defun my/org-cliplink-confluence--get (url authorization-header filter-fun)
  (let ((url-request-extra-headers `(,authorization-header
                                     ("Content-type" . "application/json; charset=utf-8")))
        (json-key-type 'keyword)
        (json-object-type 'plist))
    (with-temp-buffer
      (url-insert-file-contents url)
      (let ((content (json-read)))
        (funcall filter-fun content)))))

url은 API 주소다. Conflunce 6.x 기준으로 페이지 정보를 가져오는 API는 http://CONFLUENCE_URL/rest/api/content/PAGEID 형식이다. 세 번째 인자인 filter-fun을 Property List로 변환한 HTTP GET 메서드 응답을 필터링해서 원하는 필드 정보만 뽑는다.

my/org-cliplink-confluence--get 함수에 페이지 제목과 스페이스 이름만 필터링하는 필터 함수를 인자로 넘겨 호출한다.

(defun my/org-cliplink-confluence--retrieve-content (url auth-header)
  (let ((filter-fun (lambda (content)
                      (let ((title (plist-get content :title))
                            (space (plist-get (plist-get content :space) :name)))
                        (list :title title :space space)))))
    (my/org-cliplink-confluence--get url
                                     auth-header
                                     filter-fun)))

위에 만든 함수를 사용해 authorization header를 만들어 호출해 본다.

(my/confluence--retrieve-content "http://confluence/rest/api/content/1234"
                                 (my/org-cliplink-confluence--auth "confluence_host"))
(:title "Confluence Page Title" :space "MySpace")

페이지 제목과 스페이스 이름을 잘 가져온다.

URL에서 host와 API URL을 매칭하고 pageId 추출

Org-cliplink와 연동할 계획이다. 그래서 URL이 현재 작성 중인 패키지의 입력이다. URL에서 auth-source로부터 id와 password를 가져올 키 역할을 하는 host와 Confluence API를 사용할 page id를 추출해야 한다.

Confluence API를 사용할 host는 인자로 받는다. url-generic-parse-url 함수를 사용해 문자열에서 host를 분리해서 사용할까 생각했지만 host에 추가 패스를 사용하는 경우가 문제다. 예를 들어 confluence 호스트가 confluence_host 이면 문제가 없는데, host/confluence 식으로 host에 추가적인 path가 붙는다면 host와 path를 추출해서 조합해야 한다.

(defun my/org-cliplink-confluence--find-host (url host-api-urls)
  (car (seq-filter (lambda (elem)
                     (let ((host (car elem)))
                       (string-match-p (regexp-quote host) url)))
                   host-api-urls)))
(my/org-cliplink-confluence--find-host
  "http://host/confluence/pages/viewpage.action?pageId=123456"
  '(("host/confluence" . "http://host/confluence")
    ("host.company.corp/confluence" . "http://host.company.corp/confluence")))
("host/confluence" . "http://host/confluence")

입력으로 host와 api url로 구성한 Association List를 받게 했다. URL에 해당하는 host와 API URL을 얻어온다.

못 찾으면 nil 을 리턴한다. 리턴 값이 nil 이 아니면 이걸로 Authorization header를 만든다.

page id는 URL에서 정규식을 사용해 Query String에서 추출한다.

(defun my/org-cliplink-confluence--page-id (url)
  (save-match-data
    (if (string-match "\\?pageId=\\([[:digit:]]+\\)" url)
        (match-string 1 url))))
(my/org-cliplink-confluence--page-id "http://confluence_host/pages/viewpage.action?pageId=123456")
123456

Confluence API URL 만들기

요청할 URL을 제외한 요청에 필요한 재료는 다 만들었다. http, https를 알아서 처리하는 게 귀찮으니 API URL을 인자로 받게 한다.

(defun my/org-cliplink-confluence--api-content-get (base-url id)
  (format "%s/rest/api/content/%s" base-url id))
(my/org-cliplink-confluence--api-content-get "http://host/confluence" 12345)
http://host/confluence/rest/api/content/12345

조립 - 대표 함수 만들기

host와 api url을 짝을 지어서 설정하게 해줘야 한다. 함수 인자보다는 custom 설정으로 노출한다. Config.local.el처럼 버전 컨트롤에서 제외한 파일에서 설정할 예정이라 함수 인자로 전달하는 것보단 더 직관적이다. 만약 함수 인자로 넘긴다고 결정하면 config.local.el 파일에서 심볼에 설정을 바인딩하고 버전 컨트롤하는 config.el 파일에서는 해당 심볼을 함수 인자로 넘겨야하기 때문이다.

(defcustom my/org-cliplink-confluence-host-api-urls '()
   "Alist of api url about host
Each element has the form (HOST . API-URL)."
  :type '(alist :key-type string :value-type string)
  :group 'my/org-cliplink-confluence)
(add-to-list 'my/org-cliplink-confluence-host-api-urls '("host/confluence" . "http://host/confluence"))

이렇게 추가해주면 된다. 이제 모두 조립해서 함수를 정의한다.

(defun my/org-cliplink-confluence-title (url)
  (when-let* ((host-api (my/org-cliplink-confluence--find-host url my/org-cliplink-confluence-host-api-urls))
              (host (car host-api))
              (api-base-url (cdr host-api))
              (page-id (my/org-cliplink-confluence--page-id url))
              (api-url (my/org-cliplink-confluence--api-content-get api-base-url page-id))
              (auth-header (my/org-cliplink-confluence--auth host))
              (content (my/org-cliplink-confluence--retrieve-content
                        (my/org-cliplink-confluence--api-content-get api-base-url page-id)
                        auth-header)))
    (format "%s > %s" (plist-get content :space) (plist-get content :title))))

host와 API base URL을 추출한다. host 정보로 Auth-source에서 id와 password를 가져온다. 가져온 정보로 GET 리퀘스트 header로 사용할 authorization 헤더를 만들어서 Confluence API를 호출한다. 간단한 코드라 실패 메시지 대신 nil을 리턴하게 처리했으며 when-let 을 사용해서 심볼에 바인딩하는 값이 nil이 되면 즉각 중지하게 했다.

(my/org-cliplink-confluence-title "http://confluence/rest/api/content/1234")
MySpace > Confluence Page Title

Org-cliplink과 연동

Org-mode에서 웹페이지 링크를 삽입할 때, URL을 클립보드에 복사하고 SPC m l c 키를 누른다. 바인딩한 키는 my/org-cliplink 함수를 호출하는데, 클립보드에서 URL을 가져와 웹페이지를 방문해 타이틀을 가져온다. 가져온 타이틀로 org-mode의 link 마크업 텍스트를 삽입한다. 별도의 함수를 추가하지 않고 my/org-cliplink 함수에 기능을 추가해 보자. Confluence API를 호출해 웹페이지 정보를 가져오거나 기존처럼 방문해서 웹페이지 타이틀을 가져오게 한다.

my-org-cliplink 라이브러리와 my-org-cliplink-confluence 라이브러리 사이에 의존성을 만들지 않는다. my/org-cliplink 에 타이틀을 가져오는 함수를 정의할 수 있게 변수를 선언한다. 해당 변수로 선언된 함수를 호출해서 nil이 아니면 사용하고 nil이면 기존처럼 웹페이지 타이틀을 가져와서 사용하게 한다.

(defcustom my/org-cliplink-custom-retrieve-title-hook nil
  "Used when retrieving the title using a method other than obtaining the title by visiting a web page.
If nil is returned, the web page is visited and the title is obtained.

e.g. A page that obtains the title using the API. jira, confluence."
  :type 'hook
  :group 'my/org-cliplink)

(defun my/org-cliplink ()
  (interactive)
  (let* ((url (org-cliplink-clipboard-content))
         (title (when my/org-cliplink-custom-retrieve-title-hook
                  (funcall my/org-cliplink-custom-retrieve-title-hook url))))
    (if title
        (insert (funcall #'my/org-cliplink-link-transformer url title))
      (org-cliplink-insert-transformed-title
       url
       #'my/org-cliplink-link-transformer))))

my/org-cliplink-custom-retrieve-title-hook 변수에 my-org-cliplink-confluence 라이브러리를 사용해 웹페이지 정보를 가져오는 함수를 정의하면 된다.

(setq my/org-cliplink-custom-retrieve-title-hook
      #'my/org-cliplink-confluence-title)

마치며

HTTP 기본 인증만 지원하는 Confluence 6.x API를 호출해서 페이지 정보를 가져오는 라이브러리를 만들었다. Org-cliplink 라이브러리를 확장해서 Confluence API를 사용해야 하는 주소이면 API를 사용하고 아니면 페이지 정보를 긁어서 타이틀을 가져온다. 많이 사용하고 손에 익은 SPC m l c 키바인딩을 그대로 사용할 수 있어서 편리하다.

링크

C-x C-s C-x C-c