rpc.py 12.9 KB
Newer Older
1
from collections import defaultdict
2
from typing import Any, Callable, Dict, List, NewType, Union
3

4
from sqlalchemy import and_, literal
5

6
7
import aurweb.config as config

8
from aurweb import db, defaults, models, util
9
from aurweb.exceptions import RPCError
10
from aurweb.packages.search import RPCSearch
11

12
13
14
15
16
17
18
19
TYPE_MAPPING = {
    "depends": "Depends",
    "makedepends": "MakeDepends",
    "checkdepends": "CheckDepends",
    "optdepends": "OptDepends",
    "conflicts": "Conflicts",
    "provides": "Provides",
    "replaces": "Replaces",
20
21
}

22
23
24
25
DataGenerator = NewType("DataGenerator",
                        Callable[[models.Package], Dict[str, Any]])


26
27
class RPC:
    """ RPC API handler class.
28

29
30
31
32
33
34
    There are various pieces to RPC's process, and encapsulating them
    inside of a class means that external users do not abuse the
    RPC implementation to achieve goals. We call type handlers
    by taking a reference to the callback named "_handle_{type}_type(...)",
    and if the handler does not exist, we return a not implemented
    error to the API user.
35

36
37
    EXPOSED_VERSIONS holds the set of versions that the API
    officially supports.
38

39
40
    EXPOSED_TYPES holds the set of types that the API officially
    supports.
41

42
    ALIASES holds an alias mapping of type -> type strings.
43

44
45
46
    We should focus on privatizing implementation helpers and
    focusing on performance in the code used.
    """
47

48
49
    # A set of RPC versions supported by this API.
    EXPOSED_VERSIONS = {5}
50

51
52
53
54
55
    # A set of RPC types supported by this API.
    EXPOSED_TYPES = {
        "info", "multiinfo",
        "search", "msearch",
        "suggest", "suggest-pkgbase"
56
57
    }

58
59
60
61
62
63
64
65
66
67
    # A mapping of type aliases.
    TYPE_ALIASES = {"info": "multiinfo"}

    EXPOSED_BYS = {
        "name-desc", "name", "maintainer",
        "depends", "makedepends", "optdepends", "checkdepends"
    }

    # A mapping of by aliases.
    BY_ALIASES = {"name-desc": "nd", "name": "n", "maintainer": "m"}
68

69
    def __init__(self, version: int = 0, type: str = None) -> "RPC":
70
        self.version = version
71
        self.type = RPC.TYPE_ALIASES.get(type, type)
72

73
    def error(self, message: str) -> Dict[str, Any]:
74
75
76
77
78
79
80
81
        return {
            "version": self.version,
            "results": [],
            "resultcount": 0,
            "type": "error",
            "error": message
        }

82
    def _verify_inputs(self, by: str = [], args: List[str] = []) -> None:
83
        if self.version is None:
84
85
            raise RPCError("Please specify an API version.")

86
        if self.version not in RPC.EXPOSED_VERSIONS:
87
88
            raise RPCError("Invalid version specified.")

89
90
91
92
        if by not in RPC.EXPOSED_BYS:
            raise RPCError("Incorrect by field specified.")

        if self.type is None:
93
94
            raise RPCError("No request type/data specified.")

95
        if self.type not in RPC.EXPOSED_TYPES:
96
97
            raise RPCError("Incorrect request type specified.")

98
    def _enforce_args(self, args: List[str]) -> None:
99
100
101
        if not args:
            raise RPCError("No request type/data specified.")

102
    def _get_json_data(self, package: models.Package) -> Dict[str, Any]:
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
        """ Produce dictionary data of one Package that can be JSON-serialized.

        :param package: Package instance
        :returns: JSON-serializable dictionary
        """

        # Produce RPC API compatible Popularity: If zero, it's an integer
        # 0, otherwise, it's formatted to the 6th decimal place.
        pop = package.PackageBase.Popularity
        pop = 0 if not pop else float(util.number_format(pop, 6))

        snapshot_uri = config.get("options", "snapshot_uri")
        data = defaultdict(list)
        data.update({
            "ID": package.ID,
            "Name": package.Name,
            "PackageBaseID": package.PackageBaseID,
            "PackageBase": package.PackageBase.Name,
            # Maintainer should be set following this update if one exists.
            "Maintainer": None,
            "Version": package.Version,
            "Description": package.Description,
            "URL": package.URL,
            "URLPath": snapshot_uri % package.Name,
            "NumVotes": package.PackageBase.NumVotes,
            "Popularity": pop,
            "OutOfDate": package.PackageBase.OutOfDateTS,
            "FirstSubmitted": package.PackageBase.SubmittedTS,
131
132
133
134
135
136
137
138
139
            "LastModified": package.PackageBase.ModifiedTS
        })

        if package.PackageBase.Maintainer is not None:
            # We do have a maintainer: set the Maintainer key.
            data["Maintainer"] = package.PackageBase.Maintainer.Username

        return data

140
    def _get_info_json_data(self, package: models.Package) -> Dict[str, Any]:
141
142
        data = self._get_json_data(package)

143
144
        # All info results have _at least_ an empty list of
        # License and Keywords.
145
        data.update({
146
147
            "License": [],
            "Keywords": []
148
149
        })

150
151
152
153
154
        # If we actually got extra_info records, update data with
        # them for this particular package.
        if self.extra_info:
            data.update(self.extra_info.get(package.ID, {}))

155
156
        return data

157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
    def _assemble_json_data(self, packages: List[models.Package],
                            data_generator: DataGenerator) \
            -> List[Dict[str, Any]]:
        """
        Assemble JSON data out of a list of packages.

        :param packages: A list of Package instances or a Package ORM query
        :param data_generator: Generator callable of single-Package JSON data
        """
        output = []
        for pkg in packages:
            db.refresh(pkg)
            output.append(data_generator(pkg))
        return output

    def _handle_multiinfo_type(self, args: List[str] = [], **kwargs) \
            -> List[Dict[str, Any]]:
174
        self._enforce_args(args)
175
        args = set(args)
176
177

        packages = db.query(models.Package).join(models.PackageBase).filter(
178
            models.Package.Name.in_(args))
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
        ids = {pkg.ID for pkg in packages}

        # Aliases for 80-width.
        Package = models.Package
        PackageKeyword = models.PackageKeyword

        subqueries = [
            # PackageDependency
            db.query(
                models.PackageDependency
            ).join(models.DependencyType).filter(
                models.PackageDependency.PackageID.in_(ids)
            ).with_entities(
                models.PackageDependency.PackageID.label("ID"),
                models.DependencyType.Name.label("Type"),
                models.PackageDependency.DepName.label("Name"),
                models.PackageDependency.DepCondition.label("Cond")
            ).distinct().order_by("ID"),

            # PackageRelation
            db.query(
                models.PackageRelation
            ).join(models.RelationType).filter(
                models.PackageRelation.PackageID.in_(ids)
            ).with_entities(
                models.PackageRelation.PackageID.label("ID"),
                models.RelationType.Name.label("Type"),
                models.PackageRelation.RelName.label("Name"),
                models.PackageRelation.RelCondition.label("Cond")
            ).distinct().order_by("ID"),

            # Groups
            db.query(models.PackageGroup).join(
                models.Group,
                and_(models.PackageGroup.GroupID == models.Group.ID,
                     models.PackageGroup.PackageID.in_(ids))
            ).with_entities(
                models.PackageGroup.PackageID.label("ID"),
                literal("Groups").label("Type"),
                models.Group.Name.label("Name"),
                literal(str()).label("Cond")
            ).distinct().order_by("ID"),

            # Licenses
            db.query(models.PackageLicense).join(
                models.License,
                models.PackageLicense.LicenseID == models.License.ID
            ).filter(
                models.PackageLicense.PackageID.in_(ids)
            ).with_entities(
                models.PackageLicense.PackageID.label("ID"),
                literal("License").label("Type"),
                models.License.Name.label("Name"),
                literal(str()).label("Cond")
            ).distinct().order_by("ID"),

            # Keywords
            db.query(models.PackageKeyword).join(
                models.Package,
                and_(Package.PackageBaseID == PackageKeyword.PackageBaseID,
                     Package.ID.in_(ids))
            ).with_entities(
                models.Package.ID.label("ID"),
                literal("Keywords").label("Type"),
                models.PackageKeyword.Keyword.label("Name"),
                literal(str()).label("Cond")
            ).distinct().order_by("ID")
        ]

        # Union all subqueries together.
        query = subqueries[0].union_all(*subqueries[1:])

        # Store our extra information in a class-wise dictionary,
        # which contains package id -> extra info dict mappings.
        self.extra_info = defaultdict(lambda: defaultdict(list))
        for record in query:
            type_ = TYPE_MAPPING.get(record.Type, record.Type)

            name = record.Name
            if record.Cond:
                name += record.Cond

            self.extra_info[record.ID][type_].append(name)

263
        return self._assemble_json_data(packages, self._get_info_json_data)
264

265
    def _handle_search_type(self, by: str = defaults.RPC_SEARCH_BY,
266
                            args: List[str] = []) -> List[Dict[str, Any]]:
267
268
269
270
271
272
        # If `by` isn't maintainer and we don't have any args, raise an error.
        # In maintainer's case, return all orphans if there are no args,
        # so we need args to pass through to the handler without errors.
        if by != "m" and not len(args):
            raise RPCError("No request type/data specified.")

Kevin Morris's avatar
Kevin Morris committed
273
274
        arg = args[0] if args else str()
        if by != "m" and len(arg) < 2:
275
276
277
278
279
280
281
            raise RPCError("Query arg too small.")

        search = RPCSearch()
        search.search_by(by, arg)

        max_results = config.getint("options", "max_rpc_results")
        results = search.results().limit(max_results)
282
        return self._assemble_json_data(results, self._get_json_data)
283

284
285
    def _handle_msearch_type(self, args: List[str] = [], **kwargs)\
            -> List[Dict[str, Any]]:
286
287
        return self._handle_search_type(by="m", args=args)

288
289
    def _handle_suggest_type(self, args: List[str] = [], **kwargs)\
            -> List[str]:
290
291
292
        if not args:
            return []

293
        arg = args[0]
294
295
296
        packages = db.query(models.Package.Name).join(
            models.PackageBase
        ).filter(
297
298
299
300
301
            and_(models.PackageBase.PackagerUID.isnot(None),
                 models.Package.Name.like(f"%{arg}%"))
        ).order_by(models.Package.Name.asc()).limit(20)
        return [pkg.Name for pkg in packages]

302
303
    def _handle_suggest_pkgbase_type(self, args: List[str] = [], **kwargs)\
            -> List[str]:
304
305
306
        if not args:
            return []

307
        packages = db.query(models.PackageBase.Name).filter(
308
309
310
            and_(models.PackageBase.PackagerUID.isnot(None),
                 models.PackageBase.Name.like(f"%{args[0]}%"))
        ).order_by(models.PackageBase.Name.asc()).limit(20)
311
        return [pkg.Name for pkg in packages]
312

313
314
315
316
317
318
319
320
321
322
323
324
325
    def _is_suggestion(self) -> bool:
        return self.type.startswith("suggest")

    def _handle_callback(self, by: str, args: List[str])\
            -> Union[List[Dict[str, Any]], List[str]]:
        # Get a handle to our callback and trap an RPCError with
        # an empty list of results based on callback's execution.
        callback = getattr(self, f"_handle_{self.type.replace('-', '_')}_type")
        results = callback(by=by, args=args)
        return results

    def handle(self, by: str = defaults.RPC_SEARCH_BY, args: List[str] = [])\
            -> Union[List[Dict[str, Any]], Dict[str, Any]]:
326
327
328
329
330
331
332
333
        """ Request entrypoint. A router should pass v, type and args
        to this function and expect an output dictionary to be returned.

        :param v: RPC version argument
        :param type: RPC type argument
        :param args: Deciphered list of arguments based on arg/arg[] inputs
        """
        # Prepare our output data dictionary with some basic keys.
334
        data = {"version": self.version, "type": self.type}
335
336
337

        # Run some verification on our given arguments.
        try:
338
            self._verify_inputs(by=by, args=args)
339
        except RPCError as exc:
340
            return self.error(str(exc))
341

342
        # Convert by to its aliased value if it has one.
343
        by = RPC.BY_ALIASES.get(by, by)
344

345
        # Process the requested handler.
346
        try:
347
            results = self._handle_callback(by, args)
348
349
        except RPCError as exc:
            return self.error(str(exc))
350
351
352

        # These types are special: we produce a different kind of
        # successful JSON output: a list of results.
353
        if self._is_suggestion():
354
355
356
357
358
359
360
361
            return results

        # Return JSON output.
        data.update({
            "resultcount": len(results),
            "results": results
        })
        return data