A corporation manager and dashboard for EVE Online
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

236 lines
7.6 KiB

  1. # -*- coding: utf-8 -*-
  2. from datetime import datetime
  3. import random
  4. from threading import Lock
  5. import time
  6. import requests
  7. from ..exceptions import EVEAPIError, EVEAPIForbiddenError
  8. __all__ = ["EVESwaggerInterface"]
  9. class _ESICache:
  10. """Caches ESI API responses according to their headers.
  11. This interface is thread-safe.
  12. """
  13. EXPIRATION_RATE = 0.2
  14. def __init__(self):
  15. self._public = {}
  16. self._private = {}
  17. self._lock = Lock()
  18. @staticmethod
  19. def _get_cache_policy(resp):
  20. """Calculate the expiry and availability of the given response."""
  21. if "Cache-Control" not in resp.headers:
  22. return None
  23. directives = resp.headers["Cache-Control"].lower().split(";")
  24. directives = [d.strip() for d in directives]
  25. if "public" in directives:
  26. public = True
  27. elif "private" in directives:
  28. public = False
  29. else:
  30. return None
  31. expires = resp.headers.get("Expires")
  32. if not expires:
  33. return None
  34. try:
  35. expiry = datetime.strptime(expires, "%a, %d %b %Y %H:%M:%S GMT")
  36. except ValueError:
  37. return None
  38. return public, expiry
  39. @staticmethod
  40. def freeze_dict(d):
  41. """Return a hashable string key for the given dictionary."""
  42. if not d:
  43. return "{}"
  44. items = sorted((repr(str(k)), repr(str(v))) for k, v in d.items())
  45. return "{" + ",".join(":".join(p) for p in items) + "}"
  46. def _expire(self):
  47. """Remove old entries from the cache. Assumes lock is acquired."""
  48. now = datetime.utcnow()
  49. for index in (self._private, self._public):
  50. condemned = []
  51. for key, (expiry, _) in index.items():
  52. if expiry < now:
  53. condemned.append(key)
  54. for key in condemned:
  55. del index[key]
  56. def fetch(self, key, token):
  57. """Try to look up a key in the cache. Return None if not found.
  58. The key should be a string. The token is used if a route is cached
  59. privately.
  60. This will periodically clear the cache of expired entries.
  61. """
  62. with self._lock:
  63. if random.random() < self.EXPIRATION_RATE:
  64. self._expire()
  65. if (key, token) in self._private:
  66. expiry, value = self._private[key, token]
  67. if expiry > datetime.utcnow():
  68. return value
  69. if key in self._public:
  70. expiry, value = self._public[key]
  71. if expiry > datetime.utcnow():
  72. return value
  73. return None
  74. def insert(self, key, token, value, response):
  75. """Store a key-value pair using the response as cache control."""
  76. policy = self._get_cache_policy(response)
  77. if not policy:
  78. return
  79. public, expiry = policy
  80. if expiry > datetime.utcnow():
  81. with self._lock:
  82. if public:
  83. self._public[key] = (expiry, value)
  84. else:
  85. self._private[key, token] = (expiry, value)
  86. class _ESIQueryBuilder:
  87. """Stores an ESI query that is being built by the client."""
  88. def __init__(self, esi, token):
  89. self._esi = esi
  90. self._token = token
  91. self._path = "/"
  92. def __getattr__(self, item):
  93. self._path += str(item) + "/"
  94. return self
  95. def __call__(self, item):
  96. self._path += str(item) + "/"
  97. return self
  98. def get(self, **kwargs):
  99. """Do an HTTP GET request for the built query."""
  100. return self._esi.get(self._path, self._token, params=kwargs)
  101. def post(self, **kwargs):
  102. """Do an HTTP POST request for the built query."""
  103. return self._esi.post(self._path, self._token, data=kwargs)
  104. def put(self, **kwargs):
  105. """Do an HTTP PUT request for the built query."""
  106. return self._esi.put(self._path, self._token, data=kwargs)
  107. def delete(self, **kwargs):
  108. """Do an HTTP DELETE request for the built query."""
  109. return self._esi.delete(self._path, self._token, params=kwargs)
  110. class EVESwaggerInterface:
  111. """EVE API module for the EVE Swagger Interface (ESI).
  112. There are two equivalent ways to use this interface:
  113. data = esi.get("/v3/characters/{char_id}/".format(char_id=char_id), token)
  114. data = esi(token).v3.characters(char_id).get()
  115. For more complex requests:
  116. data = esi.post("/v1/universe/names/", token, data={"ids": [entity_id]})
  117. data = esi(token).v1.universe.names.post(ids=[entity_id]})
  118. """
  119. _RETRY_WAIT = 0.5
  120. def __init__(self, session, logger):
  121. self._session = session
  122. self._logger = logger
  123. self._debug = logger.debug
  124. self._base_url = "https://esi.tech.ccp.is"
  125. self._data_source = "tranquility"
  126. self._cache = _ESICache()
  127. def __call__(self, token=None):
  128. return _ESIQueryBuilder(self, token)
  129. def _do(self, method, query, params, data, token, can_cache=False):
  130. """Execute a query using a token with the given session method.
  131. Return the JSON result, if any. Raise EVEAPIError for any errors.
  132. """
  133. if can_cache:
  134. pkey = self._cache.freeze_dict(params)
  135. key = "|".join((method.__name__, self._data_source, query, pkey))
  136. cached = self._cache.fetch(key, token)
  137. else:
  138. cached = None
  139. self._debug("[%s] [%s] %s", method.__name__.upper(),
  140. "fresh" if cached is None else "cache", query)
  141. if cached is not None:
  142. return cached
  143. headers = {"Accept": "application/json"}
  144. if token is not None:
  145. headers["Authorization"] = "Bearer " + token
  146. params = params.copy() if params else {}
  147. params["datasource"] = self._data_source
  148. url = self._base_url + query
  149. call = lambda: method(url, params=params, json=data or None,
  150. headers=headers, timeout=10)
  151. try:
  152. try:
  153. resp = call()
  154. except requests.ConnectionError:
  155. self._logger.warn("ESI request failed, retrying once")
  156. time.sleep(self._RETRY_WAIT)
  157. resp = call()
  158. resp.raise_for_status()
  159. result = resp.json() if resp.content else None
  160. except (requests.RequestException, ValueError) as exc:
  161. self._logger.exception("ESI request failed")
  162. if hasattr(exc, "response") and (exc.response is not None and
  163. exc.response.status_code == 403):
  164. raise EVEAPIForbiddenError()
  165. raise EVEAPIError()
  166. if can_cache and result is not None:
  167. self._cache.insert(key, token, result, resp)
  168. return result
  169. def get(self, query, token, params=None):
  170. """Do an HTTP GET request for a query using a token."""
  171. meth = self._session.get
  172. return self._do(meth, query, params, None, token, True)
  173. def post(self, query, token, params=None, data=None):
  174. """Do an HTTP POST request for a query using a token."""
  175. meth = self._session.post
  176. return self._do(meth, query, params, data, token)
  177. def put(self, query, token, params=None, data=None):
  178. """Do an HTTP PUT request for a query using a token."""
  179. meth = self._session.put
  180. return self._do(meth, query, params, data, token)
  181. def delete(self, query, token, params=None):
  182. """Do an HTTP DELETE request for a query using a token."""
  183. meth = self._session.delete
  184. return self._do(meth, query, params, None, token)