manyfold.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. """A fake Manyfold install for the Manyfold tests (#1471).
  2. Answers the requests Bambuddy makes the way Manyfold's v0 API does (read from
  3. Manyfold's source: the doorkeeper token endpoint, the JSON-LD serializers,
  4. ``model_files#show`` with a derivative, ``model_files#raw``), through an
  5. ``httpx.MockTransport``, and records what it was asked.
  6. """
  7. from __future__ import annotations
  8. import json
  9. from dataclasses import dataclass, field
  10. from urllib.parse import parse_qs, quote, unquote
  11. import httpx
  12. BASE = "http://manyfold.test:3214"
  13. API = "application/vnd.manyfold.v0+json"
  14. PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
  15. STL = b"solid cube\n" + b"facet normal 0 0 1\n" * 40 + b"endsolid cube\n"
  16. THREE_MF = b"PK\x03\x04" + b"\x00" * 64
  17. @dataclass
  18. class FakeFile:
  19. filename: str
  20. mime: str
  21. data: bytes
  22. name: str = ""
  23. render: bytes | None = None
  24. @dataclass
  25. class FakeManyfold:
  26. client_id: str = "app-id"
  27. client_secret: str = "app-secret"
  28. scopes: str = "public read"
  29. page_size: int = 2
  30. models: dict[str, dict] = field(default_factory=dict)
  31. token_requests: int = 0
  32. requests: list[httpx.Request] = field(default_factory=list)
  33. valid_tokens: set[str] = field(default_factory=set)
  34. token_lifetime: int = 7200
  35. def add_model(self, model_id: str, name: str, files: dict[str, FakeFile], preview: str | None = None) -> None:
  36. self.models[model_id] = {"name": name, "files": files, "preview": preview}
  37. def revoke_tokens(self) -> None:
  38. self.valid_tokens.clear()
  39. def transport(self) -> httpx.MockTransport:
  40. return httpx.MockTransport(self.handle)
  41. def client(self) -> httpx.AsyncClient:
  42. return httpx.AsyncClient(transport=self.transport())
  43. # ---- handler ----
  44. def handle(self, request: httpx.Request) -> httpx.Response:
  45. self.requests.append(request)
  46. path = unquote(request.url.raw_path.decode().split("?")[0])
  47. if request.method == "POST" and path == "/oauth/token":
  48. return self._token(request)
  49. auth = request.headers.get("Authorization", "")
  50. if not auth.startswith("Bearer ") or auth[7:] not in self.valid_tokens:
  51. return httpx.Response(401, json={"error": "unauthorized"})
  52. parts = [p for p in path.split("/") if p]
  53. api = request.headers.get("Accept") == API
  54. if parts == ["models"] and api:
  55. return self._list(request)
  56. if len(parts) == 2 and parts[0] == "models" and api:
  57. return self._model(parts[1])
  58. if len(parts) >= 4 and parts[0] == "models" and parts[2] == "raw":
  59. return self._raw(parts[1], "/".join(parts[3:]))
  60. if len(parts) == 4 and parts[0] == "models" and parts[2] == "model_files":
  61. return self._file(parts[1], parts[3], api, request)
  62. return httpx.Response(404)
  63. def _token(self, request: httpx.Request) -> httpx.Response:
  64. self.token_requests += 1
  65. form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
  66. if form.get("client_id") != self.client_id or form.get("client_secret") != self.client_secret:
  67. return httpx.Response(401, json={"error": "invalid_client"})
  68. wanted = set(form.get("scope", "").split())
  69. if not wanted <= set(self.scopes.split()):
  70. return httpx.Response(400, json={"error": "invalid_scope"})
  71. token = f"token-{self.token_requests}"
  72. self.valid_tokens.add(token)
  73. return httpx.Response(
  74. 200,
  75. json={
  76. "access_token": token,
  77. "token_type": "Bearer",
  78. "expires_in": self.token_lifetime,
  79. "scope": " ".join(sorted(wanted)),
  80. },
  81. )
  82. def _list(self, request: httpx.Request) -> httpx.Response:
  83. q = request.url.params.get("q", "")
  84. page = int(request.url.params.get("page", "1"))
  85. ids = [mid for mid, m in self.models.items() if q.lower() in m["name"].lower()]
  86. start = (page - 1) * self.page_size
  87. chunk = ids[start : start + self.page_size]
  88. pages = max(1, -(-len(ids) // self.page_size))
  89. view = {"@id": f"/models?page={page}", "first": "/models?page=1", "last": f"/models?page={pages}"}
  90. if page > 1:
  91. view["previous"] = f"/models?page={page - 1}"
  92. if page < pages:
  93. view["next"] = f"/models?page={page + 1}"
  94. return httpx.Response(
  95. 200,
  96. headers={"Content-Type": API},
  97. content=json.dumps(
  98. {
  99. "@id": "/models",
  100. "@type": "hydra:Collection",
  101. "totalItems": len(ids),
  102. "member": [
  103. {"@id": f"{BASE}/models/{mid}", "@type": "3DModel", "name": self.models[mid]["name"]}
  104. for mid in chunk
  105. ],
  106. "view": view,
  107. }
  108. ),
  109. )
  110. def _model(self, model_id: str) -> httpx.Response:
  111. model = self.models.get(model_id)
  112. if model is None:
  113. return httpx.Response(404)
  114. body = {
  115. "@id": f"{BASE}/models/{model_id}",
  116. "@type": "3DModel",
  117. "name": model["name"],
  118. "description": "A test model",
  119. "spdx:license": {"@type": "spdx:License", "licenseId": "CC-BY-4.0"},
  120. "keywords": ["test", "cube"],
  121. "hasPart": [
  122. {
  123. "@id": f"{BASE}/models/{model_id}/model_files/{fid}",
  124. "@type": "3DModel",
  125. "name": f.name or fid,
  126. "encodingFormat": f.mime,
  127. }
  128. for fid, f in model["files"].items()
  129. ],
  130. }
  131. if model["preview"]:
  132. body["preview_file"] = {
  133. "@id": f"{BASE}/models/{model_id}/model_files/{model['preview']}",
  134. "@type": "3DModel",
  135. }
  136. return httpx.Response(200, headers={"Content-Type": API}, content=json.dumps(body))
  137. def _file(self, model_id: str, file_part: str, api: bool, request: httpx.Request) -> httpx.Response:
  138. file_id = file_part.split(".", 1)[0]
  139. f = self.models.get(model_id, {}).get("files", {}).get(file_id)
  140. if f is None:
  141. return httpx.Response(404)
  142. if api:
  143. return httpx.Response(
  144. 200,
  145. headers={"Content-Type": API},
  146. content=json.dumps(
  147. {
  148. "@id": f"{BASE}/models/{model_id}/model_files/{file_id}",
  149. "name": f.name or file_id,
  150. "contentUrl": f"{BASE}/models/{model_id}/raw/{quote(f.filename)}",
  151. "encodingFormat": f.mime,
  152. "contentSize": len(f.data),
  153. }
  154. ),
  155. )
  156. derivative = request.url.params.get("derivative")
  157. if derivative and f.render is not None:
  158. return httpx.Response(200, content=f.render)
  159. # Like Manyfold: no derivative means the original file.
  160. return httpx.Response(200, content=f.data)
  161. def _raw(self, model_id: str, filename: str) -> httpx.Response:
  162. for f in self.models.get(model_id, {}).get("files", {}).values():
  163. if f.filename == filename:
  164. return httpx.Response(200, content=f.data)
  165. return httpx.Response(404)