Skip to content

Commit e118b8f

Browse files
authored
PersonalAccessTokenに対応しました (#679)
* api.pyをPATに対応して既存のテストが通るところまで * 環境変数・引数にPATを指定してインスタンスを生成できるようにした * AnnofabApiをいくつか修正 * Unauthorize時のlogin&retry処理を、IdPassが渡されていた時のみにした * Patを渡された場合のlogin時、self.tokensにPatを転写するようにした * logoutの処理と対象になるように * create_test_project.pyがendpointを引数に取れるようにした * Patをcredentialsに利用する場合のtokensへの転写をコンストラクタではなくリクエストを実行する直前に行うようにした * README.mdを更新 * 正しくないコメントを修正 * input_mfa_code_via_stdin のコメントを修正 * rename test_build.py to test_local_build.py CIでのテスト対象とするため
1 parent 20304c4 commit e118b8f

File tree

8 files changed

+221
-50
lines changed

8 files changed

+221
-50
lines changed

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ password = "YYYYYY"
6565
service = build(user_id, password)
6666
```
6767

68+
### PersonalAccessTokenをコンストラクタ引数に渡す場合
69+
70+
```python
71+
# APIアクセス用のインスタンスを生成
72+
from annofabapi import build
73+
74+
75+
pat = "XXXXXX"
76+
77+
service = build(pat = pat)
78+
```
79+
80+
6881
### `.netrc`に認証情報を記載する場合
6982
`.netrc`ファイルに、AnnofabのユーザIDとパスワードを記載します。
7083

@@ -91,7 +104,13 @@ service = build_from_netrc()
91104

92105

93106
### 環境変数に認証情報を設定する場合
94-
環境変数`ANNOFAB_USER_ID``ANNOFAB_PASSWORD`にユーザIDとパスワードを設定します。
107+
108+
109+
* IDとパスワードで認証する場合
110+
* 環境変数`ANNOFAB_USER_ID``ANNOFAB_PASSWORD`にユーザIDとパスワードを設定します。
111+
* パーソナルアクセストークンで認証する場合
112+
* 環境変数`ANNOFAB_PAT`にトークンを設定します。
113+
* `ANNOFAB_PAT`が設定されている場合、`ANNOFAB_USER_ID``ANNOFAB_PASSWORD`は無視されます。
95114

96115
```python
97116
from annofabapi import build_from_env
@@ -109,6 +128,8 @@ service = build()
109128

110129
優先順位は以下の通りです。
111130
1. 環境変数
131+
1. `ANNOFAB_PAT`
132+
2. `ANNOFAB_USER_ID`及び`ANNOFAB_PASSWORD`
112133
2. `.netrc`
113134

114135

annofabapi/api.py

Lines changed: 73 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44
import time
55
from functools import wraps
66
from json import JSONDecodeError
7-
from typing import Any, Callable, Collection, Dict, Optional, Tuple
7+
from typing import Any, Callable, Collection, Dict, Optional, Tuple, Union
88

99
import backoff
1010
import requests
1111
from requests.auth import AuthBase
1212
from requests.cookies import RequestsCookieJar
1313

14+
from annofabapi.credentials import IdPass, Pat, Tokens
1415
from annofabapi.exceptions import InvalidMfaCodeError, MfaEnabledUserExecutionError, NotLoggedInError
1516
from annofabapi.generated_api import AbstractAnnofabApi
17+
from annofabapi.util.type_util import assert_noreturn
1618

1719
logger = logging.getLogger(__name__)
1820

@@ -234,34 +236,34 @@ class AnnofabApi(AbstractAnnofabApi):
234236
Web APIに対応したメソッドが存在するクラス。
235237
236238
Args:
237-
login_user_id: AnnofabにログインするときのユーザID
238-
login_password: Annofabにログインするときのパスワード
239+
credentials: Annofabにログインするときの認証情報
239240
endpoint_url: Annofab APIのエンドポイント。
240241
input_mfa_code_via_stdin: MFAコードを標準入力から入力するかどうか
242+
Falseを渡して且つMFAコードの入力を求められるアカウントを利用する場合、mfa_codeを引数にloginメソッドを直接呼び出さなければならず、そうしない場合は例外を送出する
241243
242244
Attributes:
243-
token_dict: login, refresh_tokenで取得したtoken情報
245+
tokens: login, refresh_tokenで取得したtoken情報
244246
cookies: Signed Cookie情報
245247
"""
246248

247-
def __init__(
248-
self, login_user_id: str, login_password: str, *, endpoint_url: str = DEFAULT_ENDPOINT_URL, input_mfa_code_via_stdin: bool = False
249-
) -> None:
250-
if not login_user_id or not login_password:
249+
def __init__(self, credentials: Union[IdPass, Pat], *, endpoint_url: str = DEFAULT_ENDPOINT_URL, input_mfa_code_via_stdin: bool = False) -> None:
250+
if isinstance(credentials, IdPass) and (not credentials.user_id or not credentials.password):
251251
raise ValueError("login_user_id or login_password is empty.")
252+
if isinstance(credentials, Pat) and not credentials.token:
253+
raise ValueError("pat is empty.")
252254

253-
self.login_user_id = login_user_id
254-
self.login_password = login_password
255+
self.credentials = credentials
255256
self.endpoint_url = endpoint_url
256257
self.input_mfa_code_via_stdin = input_mfa_code_via_stdin
257258
self.url_prefix = f"{endpoint_url}/api/v1"
258259
self.session = requests.Session()
259260

260-
self.token_dict: Optional[Dict[str, Any]] = None
261+
self.tokens: Union[Tokens, Pat, None] = None
261262

262263
self.cookies: Optional[RequestsCookieJar] = None
263264

264265
self.__account_id: Optional[str] = None
266+
self.__user_id: Optional[str] = None
265267

266268
class _MyToken(AuthBase):
267269
"""
@@ -328,8 +330,9 @@ def _create_kwargs(
328330
"params": new_params,
329331
"headers": headers,
330332
}
331-
if self.token_dict is not None:
332-
kwargs.update({"auth": self._MyToken(self.token_dict["id_token"])})
333+
if self.tokens is not None:
334+
token = self.tokens.auth_token
335+
kwargs.update({"auth": self._MyToken(token)})
333336

334337
if request_body is not None:
335338
if isinstance(request_body, (dict, list)):
@@ -495,6 +498,12 @@ def _request_wrapper(
495498
else:
496499
url = f"{self.url_prefix}{url_path}"
497500

501+
# patを使う場合は最初にtokensをセットする
502+
# def logoutの呼び出しでtokensがNoneになった後にAPIを呼び出しても問題ないように(IdPassの場合も、自動loginしているので、その代わり)
503+
# IdPassと同じ処理に合流させてしまうと、patが無効なときに無限ループしてしまうので、ここで1回だけ呼び出す
504+
if self.tokens is None and isinstance(self.credentials, Pat):
505+
self._login_pat(self.credentials)
506+
498507
kwargs = self._create_kwargs(query_params, header_params, request_body)
499508
response = self.session.request(method=http_method.lower(), url=url, **kwargs)
500509
# response.requestよりメソッド引数のrequest情報の方が分かりやすいので、メソッド引数のrequest情報を出力する。
@@ -512,8 +521,8 @@ def _request_wrapper(
512521
},
513522
)
514523

515-
# Unauthorized Errorならば、ログイン後に再度実行する
516-
if response.status_code == requests.codes.unauthorized:
524+
# ID/PASSが指定されており、Unauthorized Errorならば、ログイン後に再度実行する
525+
if isinstance(self.credentials, IdPass) and response.status_code == requests.codes.unauthorized:
517526
self.refresh_token()
518527
return self._request_wrapper(
519528
http_method,
@@ -616,7 +625,7 @@ def _request_get_with_cookie(self, project_id: str, url: str) -> requests.Respon
616625
#########################################
617626
# Public Method : Login
618627
#########################################
619-
def _login_respond_to_auth_challenge(self, mfa_code: str, session: str) -> Dict[str, Any]:
628+
def _login_respond_to_auth_challenge(self, id_pass: IdPass, mfa_code: str, session: str) -> Dict[str, Any]:
620629
"""
621630
MFAコードによるログインを実行します。
622631
@@ -629,7 +638,7 @@ def _login_respond_to_auth_challenge(self, mfa_code: str, session: str) -> Dict[
629638
Raises:
630639
InvalidMfaCodeError: ``self.input_mfa_code_via_stdin`` が ``False`` AND ``mfa_code`` が正しくない場合
631640
"""
632-
request_body = {"user_id": self.login_user_id, "mfa_code": mfa_code, "session": session}
641+
request_body = {"user_id": id_pass.user_id, "mfa_code": mfa_code, "session": session}
633642
url = f"{self.url_prefix}/login-respond-to-auth-challenge"
634643

635644
response = self._execute_http_request("post", url, json=request_body, raise_for_status=False)
@@ -645,7 +654,7 @@ def _login_respond_to_auth_challenge(self, mfa_code: str, session: str) -> Dict[
645654
if self.input_mfa_code_via_stdin:
646655
logger.info(new_error_message)
647656
new_mfa_code = _read_mfa_code_from_stdin()
648-
return self._login_respond_to_auth_challenge(new_mfa_code, session)
657+
return self._login_respond_to_auth_challenge(id_pass, new_mfa_code, session)
649658
else:
650659
raise InvalidMfaCodeError(new_error_message)
651660

@@ -671,7 +680,15 @@ def login(self, mfa_code: Optional[str] = None) -> None:
671680
InvalidMfaCodeError: ``self.input_mfa_code_via_stdin`` が ``False`` AND ``mfa_code`` が正しくない場合
672681
MfaEnabledUserExecutionError: ``self.input_mfa_code_via_stdin`` が ``False`` AND ``mfa_code`` が未指定の場合
673682
"""
674-
login_info = {"user_id": self.login_user_id, "password": self.login_password}
683+
if isinstance(self.credentials, IdPass):
684+
self._login_id_pass(self.credentials, mfa_code)
685+
elif isinstance(self.credentials, Pat):
686+
self._login_pat(self.credentials)
687+
else:
688+
assert_noreturn(self.credentials)
689+
690+
def _login_id_pass(self, id_pass: IdPass, mfa_code: Optional[str] = None) -> None:
691+
login_info = {"user_id": id_pass.user_id, "password": id_pass.password}
675692

676693
url = f"{self.url_prefix}/login"
677694

@@ -683,21 +700,24 @@ def login(self, mfa_code: Optional[str] = None) -> None:
683700
if self.input_mfa_code_via_stdin:
684701
mfa_code = _read_mfa_code_from_stdin()
685702
else:
686-
raise MfaEnabledUserExecutionError(self.login_user_id)
703+
raise MfaEnabledUserExecutionError(id_pass.user_id)
687704

688-
mfa_json_obj = self._login_respond_to_auth_challenge(mfa_code, login_json_obj["session"])
705+
mfa_json_obj = self._login_respond_to_auth_challenge(id_pass, mfa_code, login_json_obj["session"])
689706
token_dict = mfa_json_obj["token"]
690707
else:
691708
# `login` APIのレスポンスのスキーマがloginRespondToAuthChallengeのとき
692709
token_dict = login_json_obj["token"]
693710

694-
self.token_dict = token_dict
695-
logger.debug("Logged in successfully. user_id = %s", self.login_user_id)
711+
self.tokens = Tokens.from_dict(token_dict)
712+
logger.debug("Logged in successfully. user_id = %s", id_pass.user_id)
713+
714+
def _login_pat(self, pat: Pat) -> None:
715+
self.tokens = pat
696716

697717
def logout(self) -> None:
698718
"""
699719
ログアウトします。
700-
ログアウト後は、インスタンス変数 ``token_dict`` をNoneにします。
720+
ログアウト後は、インスタンス変数 ``tokens`` をNoneにします。
701721
702722
703723
@@ -708,27 +728,33 @@ def logout(self) -> None:
708728
NotLoggedInError: ログインしてない状態で関数を呼び出したときのエラー
709729
"""
710730

711-
if self.token_dict is None:
731+
if self.tokens is None:
712732
raise NotLoggedInError
733+
if isinstance(self.tokens, Pat):
734+
self.tokens = None
735+
return
713736

714-
request_body = self.token_dict
737+
request_body = self.tokens.to_dict()
715738
url = f"{self.url_prefix}/logout"
716739
self._execute_http_request("POST", url, json=request_body)
717-
self.token_dict = None
740+
self.tokens = None
718741

719742
def refresh_token(self) -> None:
720743
"""
721744
トークンを再発行して、新しいトークン情報をインスタンスに保持します。
745+
パーソナルアクセストークンでのアクセスをしている場合はrefreshを行いません。
722746
ログインしていない場合やリフレッシュトークンの有効期限が切れている場合は、login APIを実行して新しいトークン情報をインスタンスに保持します。
723747
724748
"""
725749

726-
if self.token_dict is None:
750+
if self.tokens is None:
727751
# 一度もログインしていないときは、login APIを実行して、トークン情報をインスタンスに保持する(login関数内でインスタンスに保持している)
728752
self.login()
729753
return
754+
if isinstance(self.tokens, Pat):
755+
return
730756

731-
request_body = {"refresh_token": self.token_dict["refresh_token"]}
757+
request_body = {"refresh_token": self.tokens.refresh_token}
732758
url = f"{self.url_prefix}/refresh-token"
733759
response = self._execute_http_request("POST", url, json=request_body)
734760

@@ -737,11 +763,12 @@ def refresh_token(self) -> None:
737763
self.login()
738764
return
739765

740-
self.token_dict = response.json()
766+
self.tokens = Tokens.from_dict(response.json())
741767

742768
#########################################
743769
# Public Method : Other
744770
#########################################
771+
745772
@property
746773
def account_id(self) -> str:
747774
"""
@@ -754,3 +781,19 @@ def account_id(self) -> str:
754781
account_id = content["account_id"]
755782
self.__account_id = account_id
756783
return account_id
784+
785+
@property
786+
def login_user_id(self) -> str:
787+
"""
788+
Annofabにログインするユーザのuser_id
789+
"""
790+
if self.__user_id is not None:
791+
return self.__user_id
792+
if isinstance(self.credentials, IdPass):
793+
self.__user_id = self.credentials.user_id
794+
return self.__user_id
795+
else:
796+
content, _ = self.get_my_account()
797+
user_id = content["user_id"]
798+
self.__user_id = user_id
799+
return user_id

annofabapi/credentials.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from dataclasses import dataclass
2+
from typing import Dict, Protocol
3+
4+
5+
class HasAuthToken(Protocol):
6+
@property
7+
def auth_token(self) -> str: ...
8+
9+
10+
@dataclass(frozen=True)
11+
class IdPass:
12+
user_id: str
13+
password: str
14+
15+
16+
@dataclass(frozen=True)
17+
class Pat(HasAuthToken):
18+
"""Personal Access Token"""
19+
20+
token: str
21+
22+
@property
23+
def auth_token(self) -> str:
24+
return f"Bearer {self.token}"
25+
26+
27+
@dataclass(frozen=True)
28+
class Tokens(HasAuthToken):
29+
"""IdPassを元にログインしたあとに取得されるトークン情報"""
30+
31+
id_token: str
32+
access_token: str
33+
refresh_token: str
34+
35+
@property
36+
def auth_token(self) -> str:
37+
return self.id_token
38+
39+
def to_dict(self) -> Dict[str, str]:
40+
return {
41+
"id_token": self.id_token,
42+
"access_token": self.access_token,
43+
"refresh_token": self.refresh_token,
44+
}
45+
46+
@staticmethod
47+
def from_dict(d: Dict[str, str]) -> "Tokens":
48+
return Tokens(id_token=d["id_token"], access_token=d["access_token"], refresh_token=d["refresh_token"])

0 commit comments

Comments
 (0)