rpc.py 9.87 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
            raise RPCError("Incorrect request type specified.")

104
105
106
107
    def _enforce_args(self, args: List[str]):
        if not args:
            raise RPCError("No request type/data specified.")

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

136
137
138
139
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
    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,
165
166
167
168
169
170
171
172
173
174
175
176
177
178
            "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({
179
180
181
182
183
184
185
186
            "License": [
                lic.License.Name for lic in package.package_licenses
            ],
            "Keywords": [
                keyword.Keyword for keyword in package.PackageBase.keywords
            ]
        })

187
188
        self._update_json_depends(package, data)
        self._update_json_relations(package, data)
189
190
        return data

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

198
199
200
201
202
203
204
205
    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
206
207
        arg = args[0] if args else str()
        if by != "m" and len(arg) < 2:
208
209
210
211
212
213
214
215
216
            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]

217
218
219
    def _handle_msearch_type(self, args: List[str] = [], **kwargs):
        return self._handle_search_type(by="m", args=args)

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

224
        arg = args[0]
225
226
227
        packages = db.query(models.Package.Name).join(
            models.PackageBase
        ).filter(
228
229
230
231
232
            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]

233
234
235
236
    def _handle_suggest_pkgbase_type(self, args: List[str] = [], **kwargs):
        if not args:
            return []

237
        packages = db.query(models.PackageBase.Name).filter(
238
239
240
            and_(models.PackageBase.PackagerUID.isnot(None),
                 models.PackageBase.Name.like(f"%{args[0]}%"))
        ).order_by(models.PackageBase.Name.asc()).limit(20)
241
        return [pkg.Name for pkg in packages]
242

243
    def handle(self, by: str = defaults.RPC_SEARCH_BY, args: List[str] = []):
244
245
246
247
248
249
250
251
        """ 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.
252
253
        if self.type in RPC.TYPE_ALIASES:
            self.type = RPC.TYPE_ALIASES.get(self.type)
254
255

        # Prepare our output data dictionary with some basic keys.
256
        data = {"version": self.version, "type": self.type}
257
258
259

        # Run some verification on our given arguments.
        try:
260
            self._verify_inputs(by=by, args=args)
261
        except RPCError as exc:
262
            return self.error(str(exc))
263

264
265
266
267
        # Convert by to its aliased value if it has one.
        if by in RPC.BY_ALIASES:
            by = RPC.BY_ALIASES.get(by)

268
269
        # Get a handle to our callback and trap an RPCError with
        # an empty list of results based on callback's execution.
270
        callback = getattr(self, f"_handle_{self.type.replace('-', '_')}_type")
271
272
273
274
        try:
            results = callback(by=by, args=args)
        except RPCError as exc:
            return self.error(str(exc))
275
276
277

        # These types are special: we produce a different kind of
        # successful JSON output: a list of results.
278
        if self.type in ("suggest", "suggest-pkgbase"):
279
280
281
282
283
284
285
286
            return results

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