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.
 
 
 
 
 

202 lines
6.4 KiB

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