Changeset View
Changeset View
Standalone View
Standalone View
swh/web/api/views/vault.py
# Copyright (C) 2015-2021 The Software Heritage developers | # Copyright (C) 2015-2022 The Software Heritage developers | ||||
# See the AUTHORS file at the top-level directory of this distribution | # See the AUTHORS file at the top-level directory of this distribution | ||||
# License: GNU Affero General Public License version 3, or any later version | # License: GNU Affero General Public License version 3, or any later version | ||||
# See top-level LICENSE file for more information | # See top-level LICENSE file for more information | ||||
from typing import Any, Dict | from typing import Any, Dict | ||||
from django.http import HttpResponse | from django.http import HttpResponse | ||||
from django.shortcuts import redirect | from django.shortcuts import redirect | ||||
from rest_framework.request import Request | |||||
from swh.model.hashutil import hash_to_hex | from swh.model.hashutil import hash_to_hex | ||||
from swh.model.swhids import CoreSWHID, ObjectType | from swh.model.swhids import CoreSWHID, ObjectType | ||||
from swh.web.api.apidoc import api_doc, format_docstring | from swh.web.api.apidoc import api_doc, format_docstring | ||||
from swh.web.api.apiurls import api_route | from swh.web.api.apiurls import api_route | ||||
from swh.web.api.views.utils import api_lookup | from swh.web.api.views.utils import api_lookup | ||||
from swh.web.common import archive, query | from swh.web.common import archive, query | ||||
from swh.web.common.exc import BadInputExc | from swh.web.common.exc import BadInputExc | ||||
▲ Show 20 Lines • Show All 51 Lines • ▼ Show 20 Lines | @api_route( | ||||
f"/vault/flat/(?P<swhid>{SWHID_RE})/", | f"/vault/flat/(?P<swhid>{SWHID_RE})/", | ||||
"api-1-vault-cook-flat", | "api-1-vault-cook-flat", | ||||
methods=["GET", "POST"], | methods=["GET", "POST"], | ||||
throttle_scope="swh_vault_cooking", | throttle_scope="swh_vault_cooking", | ||||
never_cache=True, | never_cache=True, | ||||
) | ) | ||||
@api_doc("/vault/flat/") | @api_doc("/vault/flat/") | ||||
@format_docstring() | @format_docstring() | ||||
def api_vault_cook_flat(request, swhid): | def api_vault_cook_flat(request: Request, swhid: str): | ||||
""" | """ | ||||
.. http:get:: /api/1/vault/flat/(swhid)/ | .. http:get:: /api/1/vault/flat/(swhid)/ | ||||
.. http:post:: /api/1/vault/flat/(swhid)/ | .. http:post:: /api/1/vault/flat/(swhid)/ | ||||
Request the cooking of a simple archive, typically for a directory. | Request the cooking of a simple archive, typically for a directory. | ||||
That endpoint enables to create a vault cooking task for a directory | That endpoint enables to create a vault cooking task for a directory | ||||
through a POST request or check the status of a previously created one | through a POST request or check the status of a previously created one | ||||
Show All 24 Lines | .. http:post:: /api/1/vault/flat/(swhid)/ | ||||
:>json string swhid: the identifier of the object to cook | :>json string swhid: the identifier of the object to cook | ||||
:statuscode 200: no error | :statuscode 200: no error | ||||
:statuscode 400: an invalid directory identifier has been provided | :statuscode 400: an invalid directory identifier has been provided | ||||
:statuscode 404: requested directory did not receive any cooking | :statuscode 404: requested directory did not receive any cooking | ||||
request yet (in case of GET) or can not be found in the archive | request yet (in case of GET) or can not be found in the archive | ||||
(in case of POST) | (in case of POST) | ||||
""" | """ | ||||
swhid = CoreSWHID.from_string(swhid) | parsed_swhid = CoreSWHID.from_string(swhid) | ||||
if swhid.object_type == ObjectType.DIRECTORY: | if parsed_swhid.object_type == ObjectType.DIRECTORY: | ||||
res = _dispatch_cook_progress(request, "flat", swhid) | res = _dispatch_cook_progress(request, "flat", parsed_swhid) | ||||
res["fetch_url"] = reverse( | res["fetch_url"] = reverse( | ||||
"api-1-vault-fetch-flat", | "api-1-vault-fetch-flat", | ||||
url_args={"swhid": str(swhid)}, | url_args={"swhid": swhid}, | ||||
request=request, | request=request, | ||||
) | ) | ||||
return _vault_response(res, add_legacy_items=False) | return _vault_response(res, add_legacy_items=False) | ||||
elif swhid.object_type == ObjectType.CONTENT: | elif parsed_swhid.object_type == ObjectType.CONTENT: | ||||
raise BadInputExc( | raise BadInputExc( | ||||
"Content objects do not need to be cooked, " | "Content objects do not need to be cooked, " | ||||
"use `/api/1/content/raw/` instead." | "use `/api/1/content/raw/` instead." | ||||
) | ) | ||||
elif swhid.object_type == ObjectType.REVISION: | elif parsed_swhid.object_type == ObjectType.REVISION: | ||||
# TODO: support revisions too? (the vault allows it) | # TODO: support revisions too? (the vault allows it) | ||||
raise BadInputExc( | raise BadInputExc( | ||||
"Only directories can be cooked as 'flat' bundles. " | "Only directories can be cooked as 'flat' bundles. " | ||||
"Use `/api/1/vault/gitfast/` to cook revisions, as gitfast bundles." | "Use `/api/1/vault/gitfast/` to cook revisions, as gitfast bundles." | ||||
) | ) | ||||
else: | else: | ||||
raise BadInputExc("Only directories can be cooked as 'flat' bundles.") | raise BadInputExc("Only directories can be cooked as 'flat' bundles.") | ||||
@api_route( | @api_route( | ||||
r"/vault/directory/(?P<dir_id>[0-9a-f]+)/", | r"/vault/directory/(?P<dir_id>[0-9a-f]+)/", | ||||
"api-1-vault-cook-directory", | "api-1-vault-cook-directory", | ||||
methods=["GET", "POST"], | methods=["GET", "POST"], | ||||
checksum_args=["dir_id"], | checksum_args=["dir_id"], | ||||
throttle_scope="swh_vault_cooking", | throttle_scope="swh_vault_cooking", | ||||
never_cache=True, | never_cache=True, | ||||
) | ) | ||||
@api_doc("/vault/directory/", tags=["deprecated"]) | @api_doc("/vault/directory/", tags=["deprecated"]) | ||||
@format_docstring() | @format_docstring() | ||||
def api_vault_cook_directory(request, dir_id): | def api_vault_cook_directory(request: Request, dir_id: str): | ||||
""" | """ | ||||
.. http:get:: /api/1/vault/directory/(dir_id)/ | .. http:get:: /api/1/vault/directory/(dir_id)/ | ||||
This endpoint was replaced by :http:get:`/api/1/vault/flat/(swhid)/` | This endpoint was replaced by :http:get:`/api/1/vault/flat/(swhid)/` | ||||
""" | """ | ||||
_, obj_id = query.parse_hash_with_algorithms_or_throws( | _, obj_id = query.parse_hash_with_algorithms_or_throws( | ||||
dir_id, ["sha1"], "Only sha1_git is supported." | dir_id, ["sha1"], "Only sha1_git is supported." | ||||
) | ) | ||||
swhid = f"swh:1:dir:{obj_id.hex()}" | swhid = f"swh:1:dir:{obj_id.hex()}" | ||||
res = _dispatch_cook_progress(request, "flat", CoreSWHID.from_string(swhid)) | res = _dispatch_cook_progress(request, "flat", CoreSWHID.from_string(swhid)) | ||||
res["fetch_url"] = reverse( | res["fetch_url"] = reverse( | ||||
"api-1-vault-fetch-flat", | "api-1-vault-fetch-flat", | ||||
url_args={"swhid": swhid}, | url_args={"swhid": swhid}, | ||||
request=request, | request=request, | ||||
) | ) | ||||
return _vault_response(res, add_legacy_items=True) | return _vault_response(res, add_legacy_items=True) | ||||
@api_route( | @api_route( | ||||
f"/vault/flat/(?P<swhid>{SWHID_RE})/raw/", | f"/vault/flat/(?P<swhid>{SWHID_RE})/raw/", | ||||
"api-1-vault-fetch-flat", | "api-1-vault-fetch-flat", | ||||
) | ) | ||||
@api_doc("/vault/flat/raw/") | @api_doc("/vault/flat/raw/") | ||||
def api_vault_fetch_flat(request, swhid): | def api_vault_fetch_flat(request: Request, swhid: str): | ||||
""" | """ | ||||
.. http:get:: /api/1/vault/flat/(swhid)/raw/ | .. http:get:: /api/1/vault/flat/(swhid)/raw/ | ||||
Fetch the cooked archive for a flat bundle. | Fetch the cooked archive for a flat bundle. | ||||
See :http:get:`/api/1/vault/flat/(swhid)/` to get more | See :http:get:`/api/1/vault/flat/(swhid)/` to get more | ||||
details on 'flat' bundle cooking. | details on 'flat' bundle cooking. | ||||
Show All 22 Lines | |||||
@api_route( | @api_route( | ||||
r"/vault/directory/(?P<dir_id>[0-9a-f]+)/raw/", | r"/vault/directory/(?P<dir_id>[0-9a-f]+)/raw/", | ||||
"api-1-vault-fetch-directory", | "api-1-vault-fetch-directory", | ||||
checksum_args=["dir_id"], | checksum_args=["dir_id"], | ||||
) | ) | ||||
@api_doc("/vault/directory/raw/", tags=["hidden", "deprecated"]) | @api_doc("/vault/directory/raw/", tags=["hidden", "deprecated"]) | ||||
def api_vault_fetch_directory(request, dir_id): | def api_vault_fetch_directory(request: Request, dir_id: str): | ||||
""" | """ | ||||
.. http:get:: /api/1/vault/directory/(dir_id)/raw/ | .. http:get:: /api/1/vault/directory/(dir_id)/raw/ | ||||
This endpoint was replaced by :http:get:`/api/1/vault/flat/(swhid)/raw/` | This endpoint was replaced by :http:get:`/api/1/vault/flat/(swhid)/raw/` | ||||
""" | """ | ||||
_, obj_id = query.parse_hash_with_algorithms_or_throws( | _, obj_id = query.parse_hash_with_algorithms_or_throws( | ||||
dir_id, ["sha1"], "Only sha1_git is supported." | dir_id, ["sha1"], "Only sha1_git is supported." | ||||
) | ) | ||||
Show All 11 Lines | @api_route( | ||||
f"/vault/gitfast/(?P<swhid>{SWHID_RE})/", | f"/vault/gitfast/(?P<swhid>{SWHID_RE})/", | ||||
"api-1-vault-cook-gitfast", | "api-1-vault-cook-gitfast", | ||||
methods=["GET", "POST"], | methods=["GET", "POST"], | ||||
throttle_scope="swh_vault_cooking", | throttle_scope="swh_vault_cooking", | ||||
never_cache=True, | never_cache=True, | ||||
) | ) | ||||
@api_doc("/vault/gitfast/") | @api_doc("/vault/gitfast/") | ||||
@format_docstring() | @format_docstring() | ||||
def api_vault_cook_gitfast(request, swhid): | def api_vault_cook_gitfast(request: Request, swhid: str): | ||||
""" | """ | ||||
.. http:get:: /api/1/vault/gitfast/(swhid)/ | .. http:get:: /api/1/vault/gitfast/(swhid)/ | ||||
.. http:post:: /api/1/vault/gitfast/(swhid)/ | .. http:post:: /api/1/vault/gitfast/(swhid)/ | ||||
Request the cooking of a gitfast archive for a revision or check | Request the cooking of a gitfast archive for a revision or check | ||||
its cooking status. | its cooking status. | ||||
That endpoint enables to create a vault cooking task for a revision | That endpoint enables to create a vault cooking task for a revision | ||||
Show All 25 Lines | .. http:post:: /api/1/vault/gitfast/(swhid)/ | ||||
:>json string status: the cooking task status (new/pending/done/failed) | :>json string status: the cooking task status (new/pending/done/failed) | ||||
:>json string swhid: the identifier of the object to cook | :>json string swhid: the identifier of the object to cook | ||||
:statuscode 200: no error | :statuscode 200: no error | ||||
:statuscode 404: requested directory did not receive any cooking | :statuscode 404: requested directory did not receive any cooking | ||||
request yet (in case of GET) or can not be found in the archive | request yet (in case of GET) or can not be found in the archive | ||||
(in case of POST) | (in case of POST) | ||||
""" | """ | ||||
swhid = CoreSWHID.from_string(swhid) | parsed_swhid = CoreSWHID.from_string(swhid) | ||||
if swhid.object_type == ObjectType.REVISION: | if parsed_swhid.object_type == ObjectType.REVISION: | ||||
res = _dispatch_cook_progress(request, "gitfast", swhid) | res = _dispatch_cook_progress(request, "gitfast", parsed_swhid) | ||||
res["fetch_url"] = reverse( | res["fetch_url"] = reverse( | ||||
"api-1-vault-fetch-gitfast", | "api-1-vault-fetch-gitfast", | ||||
url_args={"swhid": str(swhid)}, | url_args={"swhid": swhid}, | ||||
request=request, | request=request, | ||||
) | ) | ||||
return _vault_response(res, add_legacy_items=False) | return _vault_response(res, add_legacy_items=False) | ||||
elif swhid.object_type == ObjectType.CONTENT: | elif parsed_swhid.object_type == ObjectType.CONTENT: | ||||
raise BadInputExc( | raise BadInputExc( | ||||
"Content objects do not need to be cooked, " | "Content objects do not need to be cooked, " | ||||
"use `/api/1/content/raw/` instead." | "use `/api/1/content/raw/` instead." | ||||
) | ) | ||||
elif swhid.object_type == ObjectType.DIRECTORY: | elif parsed_swhid.object_type == ObjectType.DIRECTORY: | ||||
raise BadInputExc( | raise BadInputExc( | ||||
"Only revisions can be cooked as 'gitfast' bundles. " | "Only revisions can be cooked as 'gitfast' bundles. " | ||||
"Use `/api/1/vault/flat/` to cook directories, as flat bundles." | "Use `/api/1/vault/flat/` to cook directories, as flat bundles." | ||||
) | ) | ||||
else: | else: | ||||
raise BadInputExc("Only revisions can be cooked as 'gitfast' bundles.") | raise BadInputExc("Only revisions can be cooked as 'gitfast' bundles.") | ||||
@api_route( | @api_route( | ||||
r"/vault/revision/(?P<rev_id>[0-9a-f]+)/gitfast/", | r"/vault/revision/(?P<rev_id>[0-9a-f]+)/gitfast/", | ||||
"api-1-vault-cook-revision_gitfast", | "api-1-vault-cook-revision_gitfast", | ||||
methods=["GET", "POST"], | methods=["GET", "POST"], | ||||
checksum_args=["rev_id"], | checksum_args=["rev_id"], | ||||
throttle_scope="swh_vault_cooking", | throttle_scope="swh_vault_cooking", | ||||
never_cache=True, | never_cache=True, | ||||
) | ) | ||||
@api_doc("/vault/revision/gitfast/", tags=["deprecated"]) | @api_doc("/vault/revision/gitfast/", tags=["deprecated"]) | ||||
@format_docstring() | @format_docstring() | ||||
def api_vault_cook_revision_gitfast(request, rev_id): | def api_vault_cook_revision_gitfast(request: Request, rev_id: str): | ||||
""" | """ | ||||
.. http:get:: /api/1/vault/revision/(rev_id)/gitfast/ | .. http:get:: /api/1/vault/revision/(rev_id)/gitfast/ | ||||
This endpoint was replaced by :http:get:`/api/1/vault/gitfast/(swhid)/` | This endpoint was replaced by :http:get:`/api/1/vault/gitfast/(swhid)/` | ||||
""" | """ | ||||
_, obj_id = query.parse_hash_with_algorithms_or_throws( | _, obj_id = query.parse_hash_with_algorithms_or_throws( | ||||
rev_id, ["sha1"], "Only sha1_git is supported." | rev_id, ["sha1"], "Only sha1_git is supported." | ||||
) | ) | ||||
swhid = f"swh:1:rev:{obj_id.hex()}" | swhid = f"swh:1:rev:{obj_id.hex()}" | ||||
res = _dispatch_cook_progress(request, "gitfast", CoreSWHID.from_string(swhid)) | res = _dispatch_cook_progress(request, "gitfast", CoreSWHID.from_string(swhid)) | ||||
res["fetch_url"] = reverse( | res["fetch_url"] = reverse( | ||||
"api-1-vault-fetch-gitfast", | "api-1-vault-fetch-gitfast", | ||||
url_args={"swhid": swhid}, | url_args={"swhid": swhid}, | ||||
request=request, | request=request, | ||||
) | ) | ||||
return _vault_response(res, add_legacy_items=True) | return _vault_response(res, add_legacy_items=True) | ||||
@api_route( | @api_route( | ||||
f"/vault/gitfast/(?P<swhid>{SWHID_RE})/raw/", | f"/vault/gitfast/(?P<swhid>{SWHID_RE})/raw/", | ||||
"api-1-vault-fetch-gitfast", | "api-1-vault-fetch-gitfast", | ||||
) | ) | ||||
@api_doc("/vault/gitfast/raw/") | @api_doc("/vault/gitfast/raw/") | ||||
def api_vault_fetch_revision_gitfast(request, swhid): | def api_vault_fetch_revision_gitfast(request: Request, swhid: str): | ||||
""" | """ | ||||
.. http:get:: /api/1/vault/gitfast/(swhid)/raw/ | .. http:get:: /api/1/vault/gitfast/(swhid)/raw/ | ||||
Fetch the cooked gitfast archive for a revision. | Fetch the cooked gitfast archive for a revision. | ||||
See :http:get:`/api/1/vault/gitfast/(swhid)/` to get more | See :http:get:`/api/1/vault/gitfast/(swhid)/` to get more | ||||
details on gitfast cooking. | details on gitfast cooking. | ||||
Show All 22 Lines | |||||
@api_route( | @api_route( | ||||
r"/vault/revision/(?P<rev_id>[0-9a-f]+)/gitfast/raw/", | r"/vault/revision/(?P<rev_id>[0-9a-f]+)/gitfast/raw/", | ||||
"api-1-vault-fetch-revision_gitfast", | "api-1-vault-fetch-revision_gitfast", | ||||
checksum_args=["rev_id"], | checksum_args=["rev_id"], | ||||
) | ) | ||||
@api_doc("/vault/revision_gitfast/raw/", tags=["hidden", "deprecated"]) | @api_doc("/vault/revision_gitfast/raw/", tags=["hidden", "deprecated"]) | ||||
def _api_vault_revision_gitfast_raw(request, rev_id): | def _api_vault_revision_gitfast_raw(request: Request, rev_id: str): | ||||
""" | """ | ||||
.. http:get:: /api/1/vault/revision/(rev_id)/gitfast/raw/ | .. http:get:: /api/1/vault/revision/(rev_id)/gitfast/raw/ | ||||
This endpoint was replaced by :http:get:`/api/1/vault/gitfast/(swhid)/raw/` | This endpoint was replaced by :http:get:`/api/1/vault/gitfast/(swhid)/raw/` | ||||
""" | """ | ||||
rev_gitfast_raw_url = reverse( | rev_gitfast_raw_url = reverse( | ||||
"api-1-vault-fetch-gitfast", url_args={"swhid": f"swh:1:rev:{rev_id}"} | "api-1-vault-fetch-gitfast", url_args={"swhid": f"swh:1:rev:{rev_id}"} | ||||
) | ) | ||||
return redirect(rev_gitfast_raw_url) | return redirect(rev_gitfast_raw_url) | ||||
###################################################### | ###################################################### | ||||
# git_bare bundles | # git_bare bundles | ||||
@api_route( | @api_route( | ||||
f"/vault/git-bare/(?P<swhid>{SWHID_RE})/", | f"/vault/git-bare/(?P<swhid>{SWHID_RE})/", | ||||
"api-1-vault-cook-git-bare", | "api-1-vault-cook-git-bare", | ||||
methods=["GET", "POST"], | methods=["GET", "POST"], | ||||
throttle_scope="swh_vault_cooking", | throttle_scope="swh_vault_cooking", | ||||
never_cache=True, | never_cache=True, | ||||
) | ) | ||||
@api_doc("/vault/git-bare/") | @api_doc("/vault/git-bare/") | ||||
@format_docstring() | @format_docstring() | ||||
def api_vault_cook_git_bare(request, swhid): | def api_vault_cook_git_bare(request: Request, swhid: str): | ||||
""" | """ | ||||
.. http:get:: /api/1/vault/git-bare/(swhid)/ | .. http:get:: /api/1/vault/git-bare/(swhid)/ | ||||
.. http:post:: /api/1/vault/git-bare/(swhid)/ | .. http:post:: /api/1/vault/git-bare/(swhid)/ | ||||
Request the cooking of a git-bare archive for a revision or check | Request the cooking of a git-bare archive for a revision or check | ||||
its cooking status. | its cooking status. | ||||
That endpoint enables to create a vault cooking task for a revision | That endpoint enables to create a vault cooking task for a revision | ||||
Show All 29 Lines | .. http:post:: /api/1/vault/git-bare/(swhid)/ | ||||
:>json string status: the cooking task status (new/pending/done/failed) | :>json string status: the cooking task status (new/pending/done/failed) | ||||
:>json string swhid: the identifier of the object to cook | :>json string swhid: the identifier of the object to cook | ||||
:statuscode 200: no error | :statuscode 200: no error | ||||
:statuscode 404: requested directory did not receive any cooking | :statuscode 404: requested directory did not receive any cooking | ||||
request yet (in case of GET) or can not be found in the archive | request yet (in case of GET) or can not be found in the archive | ||||
(in case of POST) | (in case of POST) | ||||
""" | """ | ||||
swhid = CoreSWHID.from_string(swhid) | parsed_swhid = CoreSWHID.from_string(swhid) | ||||
if swhid.object_type == ObjectType.REVISION: | if parsed_swhid.object_type == ObjectType.REVISION: | ||||
res = _dispatch_cook_progress(request, "git_bare", swhid) | res = _dispatch_cook_progress(request, "git_bare", parsed_swhid) | ||||
res["fetch_url"] = reverse( | res["fetch_url"] = reverse( | ||||
"api-1-vault-fetch-git-bare", | "api-1-vault-fetch-git-bare", | ||||
url_args={"swhid": str(swhid)}, | url_args={"swhid": swhid}, | ||||
request=request, | request=request, | ||||
) | ) | ||||
return _vault_response(res, add_legacy_items=False) | return _vault_response(res, add_legacy_items=False) | ||||
elif swhid.object_type == ObjectType.CONTENT: | elif parsed_swhid.object_type == ObjectType.CONTENT: | ||||
raise BadInputExc( | raise BadInputExc( | ||||
"Content objects do not need to be cooked, " | "Content objects do not need to be cooked, " | ||||
"use `/api/1/content/raw/` instead." | "use `/api/1/content/raw/` instead." | ||||
) | ) | ||||
elif swhid.object_type == ObjectType.DIRECTORY: | elif parsed_swhid.object_type == ObjectType.DIRECTORY: | ||||
raise BadInputExc( | raise BadInputExc( | ||||
"Only revisions can be cooked as 'git-bare' bundles. " | "Only revisions can be cooked as 'git-bare' bundles. " | ||||
"Use `/api/1/vault/flat/` to cook directories, as flat bundles." | "Use `/api/1/vault/flat/` to cook directories, as flat bundles." | ||||
) | ) | ||||
else: | else: | ||||
raise BadInputExc("Only revisions can be cooked as 'git-bare' bundles.") | raise BadInputExc("Only revisions can be cooked as 'git-bare' bundles.") | ||||
@api_route( | @api_route( | ||||
f"/vault/git-bare/(?P<swhid>{SWHID_RE})/raw/", | f"/vault/git-bare/(?P<swhid>{SWHID_RE})/raw/", | ||||
"api-1-vault-fetch-git-bare", | "api-1-vault-fetch-git-bare", | ||||
) | ) | ||||
@api_doc("/vault/git-bare/raw/") | @api_doc("/vault/git-bare/raw/") | ||||
def api_vault_fetch_revision_git_bare(request, swhid): | def api_vault_fetch_revision_git_bare(request: Request, swhid: str): | ||||
""" | """ | ||||
.. http:get:: /api/1/vault/git-bare/(swhid)/raw/ | .. http:get:: /api/1/vault/git-bare/(swhid)/raw/ | ||||
Fetch the cooked git-bare archive for a revision. | Fetch the cooked git-bare archive for a revision. | ||||
See :http:get:`/api/1/vault/git-bare/(swhid)/` to get more | See :http:get:`/api/1/vault/git-bare/(swhid)/` to get more | ||||
details on git-bare cooking. | details on git-bare cooking. | ||||
Show All 22 Lines |