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

4
5
from sqlalchemy import and_

6
7
import aurweb.config as config

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

12
# Define dependency type mappings from ID to RPC-compatible keys.
13
DEP_TYPES = {
14
15
16
17
    dependency_type.DEPENDS_ID: "Depends",
    dependency_type.MAKEDEPENDS_ID: "MakeDepends",
    dependency_type.CHECKDEPENDS_ID: "CheckDepends",
    dependency_type.OPTDEPENDS_ID: "OptDepends"
18
19
}

20
# Define relationship type mappings from ID to RPC-compatible keys.
21
REL_TYPES = {
22
23
24
    relation_type.CONFLICTS_ID: "Conflicts",
    relation_type.PROVIDES_ID: "Provides",
    relation_type.REPLACES_ID: "Replaces"
25
26
27
}


28
29
30
31
DataGenerator = NewType("DataGenerator",
                        Callable[[models.Package], Dict[str, Any]])


32
33
class RPCError(Exception):
    pass
34
35


36
37
class RPC:
    """ RPC API handler class.
38

39
40
41
42
43
44
    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.
45

46
47
    EXPOSED_VERSIONS holds the set of versions that the API
    officially supports.
48

49
50
    EXPOSED_TYPES holds the set of types that the API officially
    supports.
51

52
    ALIASES holds an alias mapping of type -> type strings.
53

54
55
56
    We should focus on privatizing implementation helpers and
    focusing on performance in the code used.
    """
57

58
59
    # A set of RPC versions supported by this API.
    EXPOSED_VERSIONS = {5}
60

61
62
63
64
65
    # A set of RPC types supported by this API.
    EXPOSED_TYPES = {
        "info", "multiinfo",
        "search", "msearch",
        "suggest", "suggest-pkgbase"
66
67
    }

68
69
70
71
72
73
74
75
76
77
    # 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"}
78

79
80
81
82
83
84
85
86
87
88
89
90
91
    def __init__(self, version: int = 0, type: str = None):
        self.version = version
        self.type = type

    def error(self, message: str) -> dict:
        return {
            "version": self.version,
            "results": [],
            "resultcount": 0,
            "type": "error",
            "error": message
        }

92
    def _verify_inputs(self, by: str = [], args: List[str] = []):
93
        if self.version is None:
94
95
            raise RPCError("Please specify an API version.")

96
        if self.version not in RPC.EXPOSED_VERSIONS:
97
98
            raise RPCError("Invalid version specified.")

99
100
101
102
        if by not in RPC.EXPOSED_BYS:
            raise RPCError("Incorrect by field specified.")

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

105
        if self.type not in RPC.EXPOSED_TYPES:
106
107
            raise RPCError("Incorrect request type specified.")

108
109
110
111
    def _enforce_args(self, args: List[str]):
        if not args:
            raise RPCError("No request type/data specified.")

112
113
114
115
    def _update_json_depends(self, package: models.Package,
                             data: Dict[str, Any]):
        # Walk through all related PackageDependencies and produce
        # the appropriate dict entries.
116
        for dep in package.package_dependencies:
117
118
119
120
121
122
123
124
125
126
127
128
129
            if dep.DepTypeID in DEP_TYPES:
                key = DEP_TYPES.get(dep.DepTypeID)

                display = dep.DepName
                if dep.DepCondition:
                    display += dep.DepCondition

                data[key].append(display)

    def _update_json_relations(self, package: models.Package,
                               data: Dict[str, Any]):
        # Walk through all related PackageRelations and produce
        # the appropriate dict entries.
130
        for rel in package.package_relations:
131
132
133
134
135
136
137
138
139
            if rel.RelTypeID in REL_TYPES:
                key = REL_TYPES.get(rel.RelTypeID)

                display = rel.RelName
                if rel.RelCondition:
                    display += rel.RelCondition

                data[key].append(display)

140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
    def _get_json_data(self, package: models.Package):
        """ 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,
169
170
171
172
173
174
175
176
177
178
179
180
181
182
            "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

    def _get_info_json_data(self, package: models.Package):
        data = self._get_json_data(package)

        # Add licenses and keywords to info output.
        data.update({
183
184
185
186
187
188
189
190
            "License": [
                lic.License.Name for lic in package.package_licenses
            ],
            "Keywords": [
                keyword.Keyword for keyword in package.PackageBase.keywords
            ]
        })

191
192
        self._update_json_depends(package, data)
        self._update_json_relations(package, data)
193
194
        return data

195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
    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]]:
212
        self._enforce_args(args)
213
214
215
        args = set(args)
        packages = db.query(models.Package).filter(
            models.Package.Name.in_(args))
216
        return self._assemble_json_data(packages, self._get_info_json_data)
217

218
    def _handle_search_type(self, by: str = defaults.RPC_SEARCH_BY,
219
220
                            args: List[str] = []) \
            -> List[Dict[str, Any]]:
221
222
223
224
225
226
        # 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
227
228
        arg = args[0] if args else str()
        if by != "m" and len(arg) < 2:
229
230
231
232
233
234
235
            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)
236
        return self._assemble_json_data(results, self._get_json_data)
237

238
239
240
    def _handle_msearch_type(self, args: List[str] = [], **kwargs):
        return self._handle_search_type(by="m", args=args)

241
242
243
244
    def _handle_suggest_type(self, args: List[str] = [], **kwargs):
        if not args:
            return []

245
        arg = args[0]
246
247
248
        packages = db.query(models.Package.Name).join(
            models.PackageBase
        ).filter(
249
250
251
252
253
            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]

254
255
256
257
    def _handle_suggest_pkgbase_type(self, args: List[str] = [], **kwargs):
        if not args:
            return []

258
        packages = db.query(models.PackageBase.Name).filter(
259
260
261
            and_(models.PackageBase.PackagerUID.isnot(None),
                 models.PackageBase.Name.like(f"%{args[0]}%"))
        ).order_by(models.PackageBase.Name.asc()).limit(20)
262
        return [pkg.Name for pkg in packages]
263

264
    def handle(self, by: str = defaults.RPC_SEARCH_BY, args: List[str] = []):
265
266
267
268
269
270
271
272
        """ 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
        """
        # Convert type aliased types.
273
274
        if self.type in RPC.TYPE_ALIASES:
            self.type = RPC.TYPE_ALIASES.get(self.type)
275
276

        # Prepare our output data dictionary with some basic keys.
277
        data = {"version": self.version, "type": self.type}
278
279
280

        # Run some verification on our given arguments.
        try:
281
            self._verify_inputs(by=by, args=args)
282
        except RPCError as exc:
283
            return self.error(str(exc))
284

285
286
287
288
        # Convert by to its aliased value if it has one.
        if by in RPC.BY_ALIASES:
            by = RPC.BY_ALIASES.get(by)

289
290
        # Get a handle to our callback and trap an RPCError with
        # an empty list of results based on callback's execution.
291
        callback = getattr(self, f"_handle_{self.type.replace('-', '_')}_type")
292
293
294
295
        try:
            results = callback(by=by, args=args)
        except RPCError as exc:
            return self.error(str(exc))
296
297
298

        # These types are special: we produce a different kind of
        # successful JSON output: a list of results.
299
        if self.type in ("suggest", "suggest-pkgbase"):
300
301
302
303
304
305
306
307
            return results

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