mirror of
https://gitlab.archlinux.org/archlinux/aurweb.git
synced 2025-02-03 10:43:03 +01:00
Merge branch 'aur-api-v6' into 'master'
Draft: feat: metadata REST-API v6 See merge request archlinux/aurweb!595
This commit is contained in:
commit
52d78c9c28
7 changed files with 2104 additions and 0 deletions
439
aurweb/api.py
Normal file
439
aurweb/api.py
Normal file
|
@ -0,0 +1,439 @@
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Float, and_, cast, literal
|
||||||
|
from sqlalchemy.orm import Query, aliased
|
||||||
|
from sqlalchemy.sql.expression import func
|
||||||
|
|
||||||
|
from aurweb import config, db, models
|
||||||
|
from aurweb.exceptions import APIError
|
||||||
|
from aurweb.models import (
|
||||||
|
DependencyType,
|
||||||
|
Group,
|
||||||
|
Package,
|
||||||
|
PackageBase,
|
||||||
|
PackageComaintainer,
|
||||||
|
PackageDependency,
|
||||||
|
PackageGroup,
|
||||||
|
PackageKeyword,
|
||||||
|
PackageLicense,
|
||||||
|
PackageRelation,
|
||||||
|
RelationType,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from aurweb.models.dependency_type import (
|
||||||
|
CHECKDEPENDS_ID,
|
||||||
|
DEPENDS_ID,
|
||||||
|
MAKEDEPENDS_ID,
|
||||||
|
OPTDEPENDS_ID,
|
||||||
|
)
|
||||||
|
from aurweb.models.relation_type import CONFLICTS_ID, PROVIDES_ID, REPLACES_ID
|
||||||
|
|
||||||
|
VALID_VERSIONS = {6}
|
||||||
|
|
||||||
|
VALID_TYPES = {
|
||||||
|
"search",
|
||||||
|
"info",
|
||||||
|
"suggest",
|
||||||
|
"suggest-pkgbase",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_BYS_SEARCH = {
|
||||||
|
"name",
|
||||||
|
"name-desc",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_BYS_INFO = {
|
||||||
|
"name",
|
||||||
|
"maintainer",
|
||||||
|
"submitter",
|
||||||
|
"depends",
|
||||||
|
"makedepends",
|
||||||
|
"optdepends",
|
||||||
|
"checkdepends",
|
||||||
|
"provides",
|
||||||
|
"conflicts",
|
||||||
|
"replaces",
|
||||||
|
"groups",
|
||||||
|
"keywords",
|
||||||
|
"comaintainers",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_MODES = {"contains", "starts-with"}
|
||||||
|
|
||||||
|
TYPE_MAPPING = {
|
||||||
|
"depends": "Depends",
|
||||||
|
"makedepends": "MakeDepends",
|
||||||
|
"checkdepends": "CheckDepends",
|
||||||
|
"optdepends": "OptDepends",
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"provides": "Provides",
|
||||||
|
"replaces": "Replaces",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEPENDS_MAPPING = {
|
||||||
|
"depends": DEPENDS_ID,
|
||||||
|
"makedepends": MAKEDEPENDS_ID,
|
||||||
|
"optdepends": OPTDEPENDS_ID,
|
||||||
|
"checkdepends": CHECKDEPENDS_ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
RELATIONS_MAPPING = {
|
||||||
|
"provides": PROVIDES_ID,
|
||||||
|
"replaces": REPLACES_ID,
|
||||||
|
"conflicts": CONFLICTS_ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class API:
|
||||||
|
"""
|
||||||
|
API handler class.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
:param version: API version
|
||||||
|
:param type: Type of request
|
||||||
|
:param by: The field that is being used to filter our query
|
||||||
|
:param mode: The search mode (only applicable for the search type)
|
||||||
|
:param arg: A list of keywords that is used to filter our records
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, version: str, type: str, by: str, mode: str, arg: list[str]):
|
||||||
|
try:
|
||||||
|
self.version = int(version)
|
||||||
|
except ValueError:
|
||||||
|
self.version = None
|
||||||
|
self.type = type
|
||||||
|
self.by = by
|
||||||
|
self.mode = mode
|
||||||
|
self.arg = arg
|
||||||
|
|
||||||
|
# set default for empty "by" parameter
|
||||||
|
if type == "search" and (by == "" or by is None):
|
||||||
|
self.by = "name-desc"
|
||||||
|
elif by == "" or by is None:
|
||||||
|
self.by = "name"
|
||||||
|
|
||||||
|
# set default for empty "mode" parameter
|
||||||
|
if mode == "" or mode is None:
|
||||||
|
self.mode = "contains"
|
||||||
|
|
||||||
|
# base query
|
||||||
|
self.query = self._get_basequery()
|
||||||
|
|
||||||
|
def get_results(self) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Returns a dictionary with our data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# input validation
|
||||||
|
try:
|
||||||
|
self._validate_parameters()
|
||||||
|
except APIError as ex:
|
||||||
|
return self.error(str(ex))
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"resultcount": 0,
|
||||||
|
"results": [],
|
||||||
|
"type": self.type,
|
||||||
|
"version": self.version,
|
||||||
|
}
|
||||||
|
|
||||||
|
# get results according to type
|
||||||
|
try:
|
||||||
|
if self.type == "search":
|
||||||
|
data["results"] = self._search()
|
||||||
|
if self.type.startswith("suggest"):
|
||||||
|
data["suggest"] = self._suggest()
|
||||||
|
if self.type == "info":
|
||||||
|
data["results"] = self._info()
|
||||||
|
except APIError as ex:
|
||||||
|
return self.error(str(ex))
|
||||||
|
|
||||||
|
data["resultcount"] = len(data["results"])
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def error(self, message: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Returns a dictionary with an empty result set and error message.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"error": message,
|
||||||
|
"resultcount": 0,
|
||||||
|
"results": [],
|
||||||
|
"type": "error",
|
||||||
|
"version": self.version,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _validate_parameters(self):
|
||||||
|
if self.version not in VALID_VERSIONS:
|
||||||
|
raise APIError("Invalid version specified.")
|
||||||
|
if self.type not in VALID_TYPES:
|
||||||
|
raise APIError("Incorrect request type specified.")
|
||||||
|
if (self.type == "search" and self.by not in VALID_BYS_SEARCH) or (
|
||||||
|
self.type == "info" and self.by not in VALID_BYS_INFO
|
||||||
|
):
|
||||||
|
raise APIError("Incorrect by field specified.")
|
||||||
|
if self.mode not in VALID_MODES:
|
||||||
|
raise APIError("Incorrect mode specified.")
|
||||||
|
if self.type == "search" and (len(self.arg) == 0 or len(self.arg[0]) < 2):
|
||||||
|
raise APIError("Query arg too small.")
|
||||||
|
if self.type == "info" and self.by != "maintainer" and len(self.arg) == 0:
|
||||||
|
raise APIError("Query arg too small.")
|
||||||
|
|
||||||
|
def _search(self) -> list[dict[str, Any]]:
|
||||||
|
for keyword in self.arg[0].split(" "):
|
||||||
|
# define search expression according to "mode"
|
||||||
|
expression = f"{keyword}%" if self.mode == "starts-with" else f"%{keyword}%"
|
||||||
|
|
||||||
|
# name or name/desc search
|
||||||
|
if self.by == "name":
|
||||||
|
self.query = self.query.filter(Package.Name.like(expression))
|
||||||
|
else:
|
||||||
|
self.query = self.query.filter(
|
||||||
|
(Package.Name.like(expression))
|
||||||
|
| (Package.Description.like(expression))
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._run_queries()
|
||||||
|
|
||||||
|
def _suggest(self) -> list[dict[str, Any]]:
|
||||||
|
if len(self.arg) == 0:
|
||||||
|
self.arg.append("")
|
||||||
|
query = (
|
||||||
|
db.query(Package.Name)
|
||||||
|
.join(PackageBase)
|
||||||
|
.filter(
|
||||||
|
(PackageBase.PackagerUID.isnot(None))
|
||||||
|
& Package.Name.like(f"{self.arg[0]}%")
|
||||||
|
)
|
||||||
|
.order_by(Package.Name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.type == "suggest-pkgbase":
|
||||||
|
query = (
|
||||||
|
db.query(PackageBase.Name)
|
||||||
|
.filter(
|
||||||
|
(PackageBase.PackagerUID.isnot(None))
|
||||||
|
& PackageBase.Name.like(f"{self.arg[0]}%")
|
||||||
|
)
|
||||||
|
.order_by(PackageBase.Name)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = query.limit(20).all()
|
||||||
|
|
||||||
|
# "suggest" returns an array of strings
|
||||||
|
return [rec[0] for rec in data]
|
||||||
|
|
||||||
|
def _info(self) -> list[dict[str, Any]]: # noqa: C901
|
||||||
|
# get unique list of arguments
|
||||||
|
args = set(self.arg)
|
||||||
|
|
||||||
|
# subquery for submitter and comaintainer queries
|
||||||
|
users = db.query(User.ID).filter(User.Username.in_(args))
|
||||||
|
|
||||||
|
# Define joins and filters for our "by" parameter
|
||||||
|
if self.by == "name":
|
||||||
|
self.query = self.query.filter(Package.Name.in_(args))
|
||||||
|
elif self.by == "maintainer":
|
||||||
|
if len(args) == 0 or self.arg[0] == "":
|
||||||
|
self.query = self.query.filter(PackageBase.MaintainerUID.is_(None))
|
||||||
|
else:
|
||||||
|
self.query = self.query.filter(PackageBase.MaintainerUID.in_(users))
|
||||||
|
elif self.by == "submitter":
|
||||||
|
self.query = self.query.filter(PackageBase.SubmitterUID.in_(users))
|
||||||
|
elif self.by in ["depends", "makedepends", "optdepends", "checkdepends"]:
|
||||||
|
self.query = self.query.join(PackageDependency).filter(
|
||||||
|
(PackageDependency.DepTypeID == DEPENDS_MAPPING.get(self.by, 0))
|
||||||
|
& (PackageDependency.DepName.in_(args))
|
||||||
|
)
|
||||||
|
elif self.by in ["provides", "conflicts", "replaces"]:
|
||||||
|
self.query = self.query.join(PackageRelation).filter(
|
||||||
|
(PackageRelation.RelTypeID == RELATIONS_MAPPING.get(self.by, 0))
|
||||||
|
& (PackageRelation.RelName.in_(args))
|
||||||
|
)
|
||||||
|
|
||||||
|
# A package always provides itself, so we have to include it.
|
||||||
|
# Union query is the fastest way of doing this.
|
||||||
|
if self.by == "provides":
|
||||||
|
itself = self._get_basequery().filter(Package.Name.in_(args))
|
||||||
|
self.query = self.query.union(itself)
|
||||||
|
elif self.by == "groups":
|
||||||
|
self.query = (
|
||||||
|
self.query.join(PackageGroup).join(Group).filter(Group.Name.in_(args))
|
||||||
|
)
|
||||||
|
elif self.by == "keywords":
|
||||||
|
self.query = (
|
||||||
|
self.query.join(PackageKeyword)
|
||||||
|
.filter(PackageKeyword.Keyword.in_(args))
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
elif self.by == "comaintainers":
|
||||||
|
self.query = (
|
||||||
|
self.query.join(
|
||||||
|
PackageComaintainer,
|
||||||
|
PackageBase.ID == PackageComaintainer.PackageBaseID,
|
||||||
|
)
|
||||||
|
.filter(PackageComaintainer.UsersID.in_(users))
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._run_queries()
|
||||||
|
|
||||||
|
def _run_queries(self) -> list[dict[str, Any]]:
|
||||||
|
max_results = config.getint("options", "max_rpc_results")
|
||||||
|
|
||||||
|
# get basic package data
|
||||||
|
base_data = self.query.limit(max_results + 1).all()
|
||||||
|
packages = [dict(row._asdict()) for row in base_data]
|
||||||
|
|
||||||
|
# return error if we exceed max results
|
||||||
|
if len(base_data) > max_results:
|
||||||
|
raise APIError("Too many package results.")
|
||||||
|
|
||||||
|
# get list of package IDs for our subquery
|
||||||
|
ids = {pkg.ID for pkg in base_data}
|
||||||
|
|
||||||
|
# get data from related tables
|
||||||
|
sub_data = self._get_subqueries(ids).all()
|
||||||
|
|
||||||
|
# store extended information in dict for later lookup
|
||||||
|
extra_info = defaultdict(lambda: defaultdict(list))
|
||||||
|
for record in sub_data:
|
||||||
|
type_ = TYPE_MAPPING.get(record.Type, record.Type)
|
||||||
|
|
||||||
|
name = record.Name
|
||||||
|
if record.Cond:
|
||||||
|
name += record.Cond
|
||||||
|
|
||||||
|
extra_info[record.ID][type_].append(name)
|
||||||
|
|
||||||
|
# add extended info to each package
|
||||||
|
for pkg in packages:
|
||||||
|
pkg.update(extra_info.get(pkg["ID"], []))
|
||||||
|
pkg.pop("ID") # remove ID from our results
|
||||||
|
|
||||||
|
return packages
|
||||||
|
|
||||||
|
def _get_basequery(self) -> Query:
|
||||||
|
snapshot_uri = config.get("options", "snapshot_uri")
|
||||||
|
Submitter = aliased(User)
|
||||||
|
return (
|
||||||
|
db.query(Package)
|
||||||
|
.join(PackageBase)
|
||||||
|
.join(
|
||||||
|
User,
|
||||||
|
User.ID == PackageBase.MaintainerUID,
|
||||||
|
isouter=True,
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
Submitter,
|
||||||
|
Submitter.ID == PackageBase.SubmitterUID,
|
||||||
|
isouter=True,
|
||||||
|
)
|
||||||
|
.with_entities(
|
||||||
|
Package.ID,
|
||||||
|
Package.Name,
|
||||||
|
Package.Description,
|
||||||
|
Package.Version,
|
||||||
|
PackageBase.Name.label("PackageBase"),
|
||||||
|
Package.URL,
|
||||||
|
func.replace(snapshot_uri, "%s", PackageBase.Name).label("URLPath"),
|
||||||
|
User.Username.label("Maintainer"),
|
||||||
|
Submitter.Username.label("Submitter"),
|
||||||
|
PackageBase.SubmittedTS.label("FirstSubmitted"),
|
||||||
|
PackageBase.ModifiedTS.label("LastModified"),
|
||||||
|
PackageBase.OutOfDateTS.label("OutOfDate"),
|
||||||
|
PackageBase.NumVotes,
|
||||||
|
cast(PackageBase.Popularity, Float).label("Popularity"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_subqueries(self, ids: set[int]) -> Query:
|
||||||
|
subqueries = [
|
||||||
|
# PackageDependency
|
||||||
|
db.query(PackageDependency)
|
||||||
|
.join(DependencyType)
|
||||||
|
.filter(PackageDependency.PackageID.in_(ids))
|
||||||
|
.with_entities(
|
||||||
|
PackageDependency.PackageID.label("ID"),
|
||||||
|
DependencyType.Name.label("Type"),
|
||||||
|
PackageDependency.DepName.label("Name"),
|
||||||
|
PackageDependency.DepCondition.label("Cond"),
|
||||||
|
)
|
||||||
|
.distinct() # A package could have the same dependency multiple times
|
||||||
|
.order_by("Name"),
|
||||||
|
# PackageRelation
|
||||||
|
db.query(PackageRelation)
|
||||||
|
.join(RelationType)
|
||||||
|
.filter(PackageRelation.PackageID.in_(ids))
|
||||||
|
.with_entities(
|
||||||
|
PackageRelation.PackageID.label("ID"),
|
||||||
|
RelationType.Name.label("Type"),
|
||||||
|
PackageRelation.RelName.label("Name"),
|
||||||
|
PackageRelation.RelCondition.label("Cond"),
|
||||||
|
)
|
||||||
|
.distinct() # A package could have the same relation multiple times
|
||||||
|
.order_by("Name"),
|
||||||
|
# Groups
|
||||||
|
db.query(PackageGroup)
|
||||||
|
.join(
|
||||||
|
Group,
|
||||||
|
and_(
|
||||||
|
PackageGroup.GroupID == Group.ID,
|
||||||
|
PackageGroup.PackageID.in_(ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_entities(
|
||||||
|
PackageGroup.PackageID.label("ID"),
|
||||||
|
literal("Groups").label("Type"),
|
||||||
|
Group.Name.label("Name"),
|
||||||
|
literal(str()).label("Cond"),
|
||||||
|
)
|
||||||
|
.order_by("Name"),
|
||||||
|
# Licenses
|
||||||
|
db.query(PackageLicense)
|
||||||
|
.join(models.License, PackageLicense.LicenseID == models.License.ID)
|
||||||
|
.filter(PackageLicense.PackageID.in_(ids))
|
||||||
|
.with_entities(
|
||||||
|
PackageLicense.PackageID.label("ID"),
|
||||||
|
literal("License").label("Type"),
|
||||||
|
models.License.Name.label("Name"),
|
||||||
|
literal(str()).label("Cond"),
|
||||||
|
)
|
||||||
|
.order_by("Name"),
|
||||||
|
# Keywords
|
||||||
|
db.query(PackageKeyword)
|
||||||
|
.join(
|
||||||
|
Package,
|
||||||
|
and_(
|
||||||
|
Package.PackageBaseID == PackageKeyword.PackageBaseID,
|
||||||
|
Package.ID.in_(ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_entities(
|
||||||
|
Package.ID.label("ID"),
|
||||||
|
literal("Keywords").label("Type"),
|
||||||
|
PackageKeyword.Keyword.label("Name"),
|
||||||
|
literal(str()).label("Cond"),
|
||||||
|
)
|
||||||
|
.order_by("Name"),
|
||||||
|
# Co-Maintainer
|
||||||
|
db.query(PackageComaintainer)
|
||||||
|
.join(User, User.ID == PackageComaintainer.UsersID)
|
||||||
|
.join(
|
||||||
|
Package,
|
||||||
|
Package.PackageBaseID == PackageComaintainer.PackageBaseID,
|
||||||
|
)
|
||||||
|
.with_entities(
|
||||||
|
Package.ID,
|
||||||
|
literal("CoMaintainers").label("Type"),
|
||||||
|
User.Username.label("Name"),
|
||||||
|
literal(str()).label("Cond"),
|
||||||
|
)
|
||||||
|
.distinct() # A package could have the same co-maintainer multiple times
|
||||||
|
.order_by("Name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Union all subqueries into one statement.
|
||||||
|
return subqueries[0].union_all(*subqueries[1:])
|
|
@ -85,6 +85,10 @@ class RPCError(AurwebException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class APIError(AurwebException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(AurwebException):
|
class ValidationError(AurwebException):
|
||||||
def __init__(self, data: Any, *args, **kwargs):
|
def __init__(self, data: Any, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
|
@ -5,6 +5,7 @@ See https://fastapi.tiangolo.com/tutorial/bigger-applications/
|
||||||
"""
|
"""
|
||||||
from . import (
|
from . import (
|
||||||
accounts,
|
accounts,
|
||||||
|
api,
|
||||||
auth,
|
auth,
|
||||||
html,
|
html,
|
||||||
package_maintainer,
|
package_maintainer,
|
||||||
|
@ -23,6 +24,7 @@ to a fastapi.APIRouter.
|
||||||
"""
|
"""
|
||||||
APP_ROUTES = [
|
APP_ROUTES = [
|
||||||
accounts,
|
accounts,
|
||||||
|
api,
|
||||||
auth,
|
auth,
|
||||||
html,
|
html,
|
||||||
packages,
|
packages,
|
||||||
|
|
120
aurweb/routers/api.py
Normal file
120
aurweb/routers/api.py
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
"""
|
||||||
|
API routing module
|
||||||
|
|
||||||
|
Available routes:
|
||||||
|
|
||||||
|
- GET /api/v{version}/{type}/{arg}
|
||||||
|
- GET /api/v{version}/{type}/{by}/{arg}
|
||||||
|
- GET /api/v{version}/{type}/{by}/{mode}/{arg}
|
||||||
|
|
||||||
|
- GET /api/v{version}/{type} (query params: by, mode, arg)
|
||||||
|
|
||||||
|
- POST /api/v{version}/{type} (form values: by, mode, arg)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from http import HTTPStatus
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import orjson
|
||||||
|
from fastapi import APIRouter, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from aurweb.api import API
|
||||||
|
from aurweb.ratelimit import check_ratelimit
|
||||||
|
from aurweb.util import remove_empty
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# path based
|
||||||
|
@router.get("/api/v{version}/{type}/{arg}")
|
||||||
|
async def api_version_type_arg(request: Request, version: str, type: str, arg: str):
|
||||||
|
return handle_request(
|
||||||
|
request=request, version=version, type=type, by="", mode="", arg=list([arg])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v{version}/{type}/{by}/{arg}")
|
||||||
|
async def api_version_type_by_arg(
|
||||||
|
request: Request, version: str, type: str, by: str, arg: str
|
||||||
|
):
|
||||||
|
return handle_request(
|
||||||
|
request=request, version=version, type=type, by=by, mode="", arg=list([arg])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v{version}/{type}/{by}/{mode}/{arg}")
|
||||||
|
async def api_version_type_by_mode_arg(
|
||||||
|
request: Request, version: str, type: str, by: str, mode: str, arg: str
|
||||||
|
):
|
||||||
|
return handle_request(
|
||||||
|
request=request, version=version, type=type, by=by, mode=mode, arg=list([arg])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# query string
|
||||||
|
@router.get("/api/v{version}/{type}")
|
||||||
|
async def api_version_type(request: Request, version: str, type: str):
|
||||||
|
params = request.query_params
|
||||||
|
by = params.get("by")
|
||||||
|
mode = params.get("mode")
|
||||||
|
arg = params.getlist("arg")
|
||||||
|
|
||||||
|
return handle_request(
|
||||||
|
request=request, version=version, type=type, by=by, mode=mode, arg=arg
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# form data (POST)
|
||||||
|
@router.post("/api/v{version}/{type}")
|
||||||
|
async def api_post_version_type(request: Request, version: str, type: str):
|
||||||
|
form = await request.form()
|
||||||
|
by = form.get("by")
|
||||||
|
mode = form.get("mode")
|
||||||
|
arg = form.getlist("arg")
|
||||||
|
|
||||||
|
return handle_request(
|
||||||
|
request=request, version=version, type=type, by=by, mode=mode, arg=arg
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_request(
|
||||||
|
request: Request, version: str, type: str, by: str, mode: str, arg: list[str]
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Middleware for checking rate-limits
|
||||||
|
|
||||||
|
All routers should initiate requests through this function.
|
||||||
|
"""
|
||||||
|
api = API(version=version, type=type, by=by, mode=mode, arg=arg)
|
||||||
|
|
||||||
|
# rate limit check
|
||||||
|
if check_ratelimit(request):
|
||||||
|
return JSONResponse(
|
||||||
|
api.error("Rate limit reached"),
|
||||||
|
status_code=int(HTTPStatus.TOO_MANY_REQUESTS),
|
||||||
|
)
|
||||||
|
|
||||||
|
# run query and return results
|
||||||
|
return compose_response(api.get_results())
|
||||||
|
|
||||||
|
|
||||||
|
def compose_response(result: dict[str, Any]) -> Response:
|
||||||
|
"""
|
||||||
|
Converts our data into JSON format and generates a response.
|
||||||
|
We also check for any errors and set the http status accordingly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
status = HTTPStatus.OK
|
||||||
|
if result.get("error") is not None:
|
||||||
|
status = HTTPStatus.BAD_REQUEST
|
||||||
|
|
||||||
|
# suggest calls are returned as plain JSON string array
|
||||||
|
if result.get("suggest") is not None:
|
||||||
|
return JSONResponse(result["suggest"])
|
||||||
|
|
||||||
|
# remove null values from results and compose JSON data
|
||||||
|
result["results"] = remove_empty(result["results"])
|
||||||
|
data = orjson.dumps(result)
|
||||||
|
return Response(content=data, status_code=status, media_type="application/json")
|
|
@ -208,3 +208,16 @@ def hash_query(query: Query):
|
||||||
return sha1(
|
return sha1(
|
||||||
str(query.statement.compile(compile_kwargs={"literal_binds": True})).encode()
|
str(query.statement.compile(compile_kwargs={"literal_binds": True})).encode()
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def remove_empty(value):
|
||||||
|
"""
|
||||||
|
Recursively remove all None values from dictionaries and lists,
|
||||||
|
and return the result as a new dictionary or list.
|
||||||
|
"""
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [remove_empty(x) for x in value if x is not None]
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
return {key: remove_empty(val) for key, val in value.items() if val is not None}
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
306
doc/api.md
Normal file
306
doc/api.md
Normal file
|
@ -0,0 +1,306 @@
|
||||||
|
# aurweb v6 /api
|
||||||
|
|
||||||
|
Specification for version 6 of the metadata REST-API
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Endpoints are classified in 3 categories:
|
||||||
|
|
||||||
|
* Search -> Search for packages
|
||||||
|
* Info -> Lookup information for package (exact keyword)
|
||||||
|
* Suggest -> Search for package names (max. 20 results)
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
Performs a search query according to the `by` parameter.
|
||||||
|
|
||||||
|
* <mark>GET</mark>`/api/v6/search/{arg}`
|
||||||
|
<mark>GET</mark>`/api/v6/search/{by}/{arg}`
|
||||||
|
<mark>GET</mark>`/api/v6/search/{by}/{mode}/{arg}`
|
||||||
|
* `arg` <sub>(path; mandatory)</sub>
|
||||||
|
* `by` <sub>(path; optional)</sub>
|
||||||
|
* `mode` <sub>(path; optional)</sub>
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
* `by`
|
||||||
|
|
||||||
|
The field that is utilized in the search query
|
||||||
|
If not specified, searching is performend with ***name-desc***
|
||||||
|
* Type: `string`
|
||||||
|
* Allowed values
|
||||||
|
* `name`
|
||||||
|
* `name-desc`
|
||||||
|
* Default: `name-desc`
|
||||||
|
|
||||||
|
* `mode`
|
||||||
|
|
||||||
|
The search-mode that is being used to query packages
|
||||||
|
If not specified, searching is performend with ***contains***
|
||||||
|
* Type: `string`
|
||||||
|
* Allowed values
|
||||||
|
* `contains`
|
||||||
|
* `starts-with`
|
||||||
|
* Default: `contains`
|
||||||
|
|
||||||
|
* `arg`
|
||||||
|
|
||||||
|
The argument/search-term(s) for the search query
|
||||||
|
Multiple terms/words can be supplied (space separated)
|
||||||
|
to perform and *AND*-like query
|
||||||
|
|
||||||
|
* Type: `string`
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
Data is returned in JSON format.
|
||||||
|
Empty fields are ommitted in the output.
|
||||||
|
|
||||||
|
`200 - OK`
|
||||||
|
|
||||||
|
* PackageData
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"resultcount": 1,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"Name": "my-pkg",
|
||||||
|
"Description": "Package description",
|
||||||
|
"Version": "1.7.5-1",
|
||||||
|
"PackageBase": "my-pkg",
|
||||||
|
"URL": "https://example.com",
|
||||||
|
"URLPath": "/cgit/aur.git/snapshot/my-pkg.tar.gz",
|
||||||
|
"Maintainer": "someone",
|
||||||
|
"Submitter": "someone",
|
||||||
|
"FirstSubmitted": 1648375227,
|
||||||
|
"LastModified": 1666386881,
|
||||||
|
"OutOfDate": 1648375227,
|
||||||
|
"NumVotes": 10,
|
||||||
|
"Popularity": 6.463867,
|
||||||
|
"License": [
|
||||||
|
"MIT",
|
||||||
|
"GPL3"
|
||||||
|
],
|
||||||
|
"Depends": [
|
||||||
|
"some-pkg",
|
||||||
|
"another-pkg"
|
||||||
|
],
|
||||||
|
"MakeDepends": [
|
||||||
|
"some-pkg",
|
||||||
|
"another-pkg"
|
||||||
|
],
|
||||||
|
"OptDepends": [
|
||||||
|
"some-pkg",
|
||||||
|
"another-pkg"
|
||||||
|
],
|
||||||
|
"CheckDepends": [
|
||||||
|
"some-pkg",
|
||||||
|
"another-pkg"
|
||||||
|
],
|
||||||
|
"Provides": [
|
||||||
|
"some-pkg",
|
||||||
|
"another-pkg"
|
||||||
|
],
|
||||||
|
"Conflicts": [
|
||||||
|
"some-pkg",
|
||||||
|
"another-pkg"
|
||||||
|
],
|
||||||
|
"Replaces": [
|
||||||
|
"some-pkg",
|
||||||
|
"another-pkg"
|
||||||
|
],
|
||||||
|
"Groups": [
|
||||||
|
"some-grp",
|
||||||
|
"another-grp"
|
||||||
|
],
|
||||||
|
"Keywords": [
|
||||||
|
"some-keyword",
|
||||||
|
"another-keyword"
|
||||||
|
],
|
||||||
|
"CoMaintainers": [
|
||||||
|
"someone",
|
||||||
|
"another-one"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "search",
|
||||||
|
"version": 6
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`400 - Bad Request`
|
||||||
|
|
||||||
|
* Error
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Incorrect by field specified",
|
||||||
|
"resultcount": 1,
|
||||||
|
"results": [],
|
||||||
|
"type": "error",
|
||||||
|
"version": 6
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Info
|
||||||
|
|
||||||
|
Returns a list of detailed package data for one or more packages
|
||||||
|
|
||||||
|
#### Single lookup
|
||||||
|
* <mark>GET</mark>`/api/v6/info/{arg}`
|
||||||
|
<mark>GET</mark>`/api/v6/info/{by}/{arg}`
|
||||||
|
* `arg` <sub>(path; mandatory)</sub>
|
||||||
|
* `by` <sub>(path; optional)</sub>
|
||||||
|
|
||||||
|
#### Multi lookup
|
||||||
|
* <mark>GET</mark>`/api/v6/info?arg=xyz&arg=abc`
|
||||||
|
<mark>GET</mark>`/api/v6/info?by=provides&arg=xyz&arg=abc`
|
||||||
|
* `arg` <sub>(query-string; mandatory; one or more)</sub>
|
||||||
|
* `by` <sub>(query-string; optional)</sub>
|
||||||
|
|
||||||
|
* <mark>POST</mark>`/api/v6/info`
|
||||||
|
* BODY (`application/x-www-form-urlencoded`):
|
||||||
|
```xml
|
||||||
|
arg=one&arg=two&by=provides
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
* `by`
|
||||||
|
|
||||||
|
The field is being utilized in the lookup query
|
||||||
|
If not specified, a lookup is performend with ***name***
|
||||||
|
* Type: `string`
|
||||||
|
* Allowed values
|
||||||
|
* `name`
|
||||||
|
* `depends`
|
||||||
|
* `checkdepends`
|
||||||
|
* `optdepends`
|
||||||
|
* `makedepends`
|
||||||
|
* `maintainer`
|
||||||
|
* `submitter`
|
||||||
|
* `provides`
|
||||||
|
* `conflicts`
|
||||||
|
* `replaces`
|
||||||
|
* `keywords`
|
||||||
|
* `groups`
|
||||||
|
* `comaintainers`
|
||||||
|
* Default: `name`
|
||||||
|
|
||||||
|
* `arg`
|
||||||
|
|
||||||
|
One or more keywords
|
||||||
|
|
||||||
|
* Type: `string` or `string-array` (depending on the endpoint)
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
Data is returned in JSON format.
|
||||||
|
Empty fields are ommitted in the output.
|
||||||
|
|
||||||
|
`200 - OK`
|
||||||
|
|
||||||
|
* PackageData
|
||||||
|
|
||||||
|
See `Search` type
|
||||||
|
|
||||||
|
`400 - Bad Request`
|
||||||
|
|
||||||
|
* Error
|
||||||
|
|
||||||
|
See `Search` type
|
||||||
|
|
||||||
|
### Suggest
|
||||||
|
|
||||||
|
Returns a list of package names starting with the supplied argument.
|
||||||
|
Mostly used for auto-completion fields when typing.
|
||||||
|
|
||||||
|
#### Starts-with search
|
||||||
|
|
||||||
|
* <mark>GET</mark>`/api/v6/suggest/{arg}`
|
||||||
|
* `arg` <sub>(path; mandatory)</sub>
|
||||||
|
|
||||||
|
* <mark>GET</mark>`/api/v6/suggest-pkgbase/{arg}`
|
||||||
|
* `arg` <sub>(path; mandatory)</sub>
|
||||||
|
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
Data is returned in JSON format.
|
||||||
|
|
||||||
|
`200 - OK`
|
||||||
|
|
||||||
|
* PackageNames
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"pkg1",
|
||||||
|
"pkg2",
|
||||||
|
"pkg3",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
* `arg`
|
||||||
|
|
||||||
|
Search term (starts-with)
|
||||||
|
|
||||||
|
* Type: `string`
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Below you'll find some basic examples for the different types of requests.
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
* packages containing `firefox` in the package name or description
|
||||||
|
|
||||||
|
<mark>GET</mark>`/api/v6/search/firefox`
|
||||||
|
<mark>GET</mark>`/api/v6/search/name-desc/firefox`
|
||||||
|
<mark>GET</mark>`/api/v6/search/name-desc/contains/firefox`
|
||||||
|
|
||||||
|
* packages whose name start with `firefox`
|
||||||
|
|
||||||
|
<mark>GET</mark>`/api/v6/search/name/starts-with/firefox`
|
||||||
|
|
||||||
|
* packages containing both `fire` and `fox` in the name
|
||||||
|
|
||||||
|
<mark>GET</mark>`/api/v6/search/name/fire+fox`
|
||||||
|
(note that `+` is a URL-encoded whitespace character)
|
||||||
|
|
||||||
|
### Info
|
||||||
|
|
||||||
|
* a package with the name `firefox`
|
||||||
|
|
||||||
|
<mark>GET</mark>`/api/v6/info/firefox`
|
||||||
|
|
||||||
|
* packages providing `firefox`
|
||||||
|
|
||||||
|
<mark>GET</mark>`/api/v6/info/provides/firefox`
|
||||||
|
<mark>GET</mark>`/api/v6/info?by=provides&arg=firefox`
|
||||||
|
|
||||||
|
* packages co-maintained by `someuser`
|
||||||
|
|
||||||
|
<mark>GET</mark>`/api/v6/info/comaintainers/someuser`
|
||||||
|
<mark>GET</mark>`/api/v6/info?by=comaintainers&arg=someuser`
|
||||||
|
|
||||||
|
* packages with name `firefox` or `chromium`
|
||||||
|
|
||||||
|
<mark>GET</mark>`/api/v6/info?by=name&arg=firefox&arg=chromium`
|
||||||
|
|
||||||
|
<mark>POST</mark>`/api/v6/info`
|
||||||
|
```
|
||||||
|
arg=firefox&arg=chromium&by=name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Suggest
|
||||||
|
|
||||||
|
* packages starting with `fire`
|
||||||
|
|
||||||
|
<mark>GET</mark>`/api/v6/suggest/fire`
|
||||||
|
|
||||||
|
* packages whose pkgbase starts with `fire`
|
||||||
|
|
||||||
|
<mark>GET</mark>`/api/v6/suggest-pkgbase/fire`
|
1220
test/test_api.py
Normal file
1220
test/test_api.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue