rpc.py 9.72 KB
Newer Older
1
from collections import defaultdict
2
from typing import Any, Dict, List
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
class RPCError(Exception):
    pass
30
31


32
33
class RPC:
    """ RPC API handler class.
34

35
36
37
38
39
40
    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.
41

42
43
    EXPOSED_VERSIONS holds the set of versions that the API
    officially supports.
44

45
46
    EXPOSED_TYPES holds the set of types that the API officially
    supports.
47

48
    ALIASES holds an alias mapping of type -> type strings.
49

50
51
52
    We should focus on privatizing implementation helpers and
    focusing on performance in the code used.
    """
53

54
55
    # A set of RPC versions supported by this API.
    EXPOSED_VERSIONS = {5}
56

57
58
59
60
61
    # A set of RPC types supported by this API.
    EXPOSED_TYPES = {
        "info", "multiinfo",
        "search", "msearch",
        "suggest", "suggest-pkgbase"
62
63
    }

64
65
66
67
68
69
70
71
72
73
    # 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"}
74

75
76
77
78
79
80
81
82
83
84
85
86
87
    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
        }

88
    def _verify_inputs(self, by: str = [], args: List[str] = []):
89
        if self.version is None:
90
91
            raise RPCError("Please specify an API version.")

92
        if self.version not in RPC.EXPOSED_VERSIONS:
93
94
            raise RPCError("Invalid version specified.")

95
96
97
98
        if by not in RPC.EXPOSED_BYS:
            raise RPCError("Incorrect by field specified.")

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

101
        if self.type not in RPC.EXPOSED_TYPES:
102
103
104
            raise RPCError("Incorrect request type specified.")

        try:
105
            getattr(self, f"_handle_{self.type.replace('-', '_')}_type")
106
        except AttributeError:
107
108
            raise RPCError(
                f"Request type '{self.type}' is not yet implemented.")
109

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

114
115
116
117
    def _update_json_depends(self, package: models.Package,
                             data: Dict[str, Any]):
        # Walk through all related PackageDependencies and produce
        # the appropriate dict entries.
118
        for dep in package.package_dependencies:
119
120
121
122
123
124
125
126
127
128
129
130
131
            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.
132
        for rel in package.package_relations:
133
134
135
136
137
138
139
140
141
            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)

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
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
    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,
            "LastModified": package.PackageBase.ModifiedTS,
            "License": [
                lic.License.Name for lic in package.package_licenses
            ],
            "Keywords": [
                keyword.Keyword for keyword in package.PackageBase.keywords
            ]
        })

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

184
185
        self._update_json_depends(package, data)
        self._update_json_relations(package, data)
186
187
        return data

188
189
    def _handle_multiinfo_type(self, args: List[str] = [], **kwargs):
        self._enforce_args(args)
190
191
192
193
194
        args = set(args)
        packages = db.query(models.Package).filter(
            models.Package.Name.in_(args))
        return [self._get_json_data(pkg) for pkg in packages]

195
196
197
198
199
200
201
202
    def _handle_search_type(self, by: str = defaults.RPC_SEARCH_BY,
                            args: List[str] = []):
        # 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
203
204
        arg = args[0] if args else str()
        if by != "m" and len(arg) < 2:
205
206
207
208
209
210
211
212
213
214
215
216
217
            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)
        return [self._get_json_data(pkg) for pkg in results]

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

218
219
220
221
222
223
224
        arg = args[0]
        packages = db.query(models.Package).join(models.PackageBase).filter(
            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]

225
226
227
228
    def _handle_suggest_pkgbase_type(self, args: List[str] = [], **kwargs):
        if not args:
            return []

229
230
231
232
233
234
        records = db.query(models.PackageBase).filter(
            and_(models.PackageBase.PackagerUID.isnot(None),
                 models.PackageBase.Name.like(f"%{args[0]}%"))
        ).order_by(models.PackageBase.Name.asc()).limit(20)
        return [record.Name for record in records]

235
    def handle(self, by: str = defaults.RPC_SEARCH_BY, args: List[str] = []):
236
237
238
239
240
241
242
243
        """ 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.
244
245
        if self.type in RPC.TYPE_ALIASES:
            self.type = RPC.TYPE_ALIASES.get(self.type)
246
247

        # Prepare our output data dictionary with some basic keys.
248
        data = {"version": self.version, "type": self.type}
249
250
251

        # Run some verification on our given arguments.
        try:
252
            self._verify_inputs(by=by, args=args)
253
        except RPCError as exc:
254
            return self.error(str(exc))
255

256
257
258
259
        # Convert by to its aliased value if it has one.
        if by in RPC.BY_ALIASES:
            by = RPC.BY_ALIASES.get(by)

260
261
        # Get a handle to our callback and trap an RPCError with
        # an empty list of results based on callback's execution.
262
        callback = getattr(self, f"_handle_{self.type.replace('-', '_')}_type")
263
264
265
266
        try:
            results = callback(by=by, args=args)
        except RPCError as exc:
            return self.error(str(exc))
267
268
269

        # These types are special: we produce a different kind of
        # successful JSON output: a list of results.
270
        if self.type in ("suggest", "suggest-pkgbase"):
271
272
273
274
275
276
277
278
            return results

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