1# This file is part of Buildbot.  Buildbot is free software: you can
2# redistribute it and/or modify it under the terms of the GNU General Public
3# License as published by the Free Software Foundation, version 2.
4#
5# This program is distributed in the hope that it will be useful, but WITHOUT
6# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
7# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
8# details.
9#
10# You should have received a copy of the GNU General Public License along with
11# this program; if not, write to the Free Software Foundation, Inc., 51
12# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
13#
14# Copyright Buildbot Team Members
15
16# See "Type Validation" in master/docs/developer/tests.rst
17import datetime
18import json
19import re
20
21from buildbot import util
22from buildbot.util import bytes2unicode
23
24
25def capitalize(word):
26    return ''.join(x.capitalize() or '_' for x in word.split('_'))
27
28
29class Type:
30
31    name = None
32    doc = None
33    graphQLType = "unknown"
34
35    @property
36    def ramlname(self):
37        return self.name
38
39    def valueFromString(self, arg):
40        # convert a urldecoded bytestring as given in a URL to a value, or
41        # raise an exception trying.  This parent method raises an exception,
42        # so if the method is missing in a subclass, it cannot be created from
43        # a string.
44        raise TypeError
45
46    def cmp(self, val, arg):
47        argVal = self.valueFromString(arg)
48        if val < argVal:
49            return -1
50        elif val == argVal:
51            return 0
52        return 1
53
54    def validate(self, name, object):
55        raise NotImplementedError
56
57    def getSpec(self):
58        r = dict(name=self.name)
59        if self.doc is not None:
60            r["doc"] = self.doc
61        return r
62
63    def toGraphQL(self):
64        return self.graphQLType
65
66    def toGraphQLTypeName(self):
67        return self.graphQLType
68
69    def graphQLDependentTypes(self):
70        return []
71
72    def getGraphQLInputType(self):
73        return self.toGraphQLTypeName()
74
75
76class NoneOk(Type):
77
78    def __init__(self, nestedType):
79        assert isinstance(nestedType, Type)
80        self.nestedType = nestedType
81        self.name = self.nestedType.name + " or None"
82
83    @property
84    def ramlname(self):
85        return self.nestedType.ramlname
86
87    def valueFromString(self, arg):
88        return self.nestedType.valueFromString(arg)
89
90    def cmp(self, val, arg):
91        return self.nestedType.cmp(val, arg)
92
93    def validate(self, name, object):
94        if object is None:
95            return
96        for msg in self.nestedType.validate(name, object):
97            yield msg
98
99    def getSpec(self):
100        r = self.nestedType.getSpec()
101        r["can_be_null"] = True
102        return r
103
104    def toRaml(self):
105        return self.nestedType.toRaml()
106
107    def toGraphQL(self):
108        # remove trailing !
109        if isinstance(self.nestedType, Entity):
110            return self.nestedType.graphql_name
111        return self.nestedType.toGraphQL()[:-1]
112
113    def graphQLDependentTypes(self):
114        return [self.nestedType]
115
116    def getGraphQLInputType(self):
117        return self.nestedType.getGraphQLInputType()
118
119
120class Instance(Type):
121
122    types = ()
123    ramlType = "unknown"
124    graphQLType = "unknown"
125
126    @property
127    def ramlname(self):
128        return self.ramlType
129
130    def validate(self, name, object):
131        if not isinstance(object, self.types):
132            yield "{} ({}) is not a {}".format(name, repr(object), self.name or repr(self.types))
133
134    def toRaml(self):
135        return self.ramlType
136
137    def toGraphQL(self):
138        return self.graphQLType + "!"
139
140
141class Integer(Instance):
142
143    name = "integer"
144    types = (int,)
145    ramlType = "integer"
146    graphQLType = "Int"
147
148    def valueFromString(self, arg):
149        return int(arg)
150
151
152class DateTime(Instance):
153
154    name = "datetime"
155    types = (datetime.datetime,)
156    ramlType = "date"
157    graphQLType = "Date"  # custom
158
159    def valueFromString(self, arg):
160        return int(arg)
161
162    def validate(self, name, object):
163        if isinstance(object, datetime.datetime):
164            return
165        if isinstance(object, int):
166            try:
167                datetime.datetime.fromtimestamp(object)
168            except (OverflowError, OSError):
169                pass
170            else:
171                return
172        yield "{} ({}) is not a valid timestamp".format(name, object)
173
174
175class String(Instance):
176
177    name = "string"
178    types = (str,)
179    ramlType = "string"
180    graphQLType = "String"
181
182    def valueFromString(self, arg):
183        val = util.bytes2unicode(arg)
184        return val
185
186
187class Binary(Instance):
188
189    name = "binary"
190    types = (bytes,)
191    ramlType = "string"
192    graphQLType = "Binary"  # custom
193
194    def valueFromString(self, arg):
195        return arg
196
197
198class Boolean(Instance):
199
200    name = "boolean"
201    types = (bool,)
202    ramlType = "boolean"
203    graphQLType = "Boolean"  # custom
204
205    def valueFromString(self, arg):
206        return util.string2boolean(arg)
207
208
209class Identifier(Type):
210
211    name = "identifier"
212    identRe = re.compile('^[a-zA-Z_-][a-zA-Z0-9._-]*$')
213    ramlType = "string"
214    graphQLType = "String"
215
216    def __init__(self, len=None, **kwargs):
217        super().__init__(**kwargs)
218        self.len = len
219
220    def valueFromString(self, arg):
221        val = util.bytes2unicode(arg)
222        if not self.identRe.match(val) or len(val) > self.len or not val:
223            raise TypeError
224        return val
225
226    def validate(self, name, object):
227        if not isinstance(object, str):
228            yield "{} - {} - is not a unicode string".format(name, repr(object))
229        elif not self.identRe.match(object):
230            yield "{} - {} - is not an identifier".format(name, repr(object))
231        elif not object:
232            yield "{} - identifiers cannot be an empty string".format(name)
233        elif len(object) > self.len:
234            yield "{} - {} - is longer than {} characters".format(name, repr(object), self.len)
235
236    def toRaml(self):
237        return {'type': self.ramlType,
238                'pattern': self.identRe.pattern}
239
240
241class List(Type):
242
243    name = "list"
244    ramlType = "list"
245
246    @property
247    def ramlname(self):
248        return self.of.ramlname
249
250    def __init__(self, of=None, **kwargs):
251        super().__init__(**kwargs)
252        self.of = of
253
254    def validate(self, name, object):
255        if not isinstance(object, list):  # we want a list, and NOT a subclass
256            yield "{} ({}) is not a {}".format(name, repr(object), self.name)
257            return
258
259        for idx, elt in enumerate(object):
260            for msg in self.of.validate("{}[{}]".format(name, idx), elt):
261                yield msg
262
263    def valueFromString(self, arg):
264        # valueFromString is used to process URL args, which come one at
265        # a time, so we defer to the `of`
266        return self.of.valueFromString(arg)
267
268    def getSpec(self):
269        return dict(type=self.name,
270                    of=self.of.getSpec())
271
272    def toRaml(self):
273        return {'type': 'array', 'items': self.of.name}
274
275    def toGraphQL(self):
276        return f"[{self.of.toGraphQLTypeName()}]!"
277
278    def toGraphQLTypeName(self):
279        return f"[{self.of.toGraphQLTypeName()}]"
280
281    def graphQLDependentTypes(self):
282        return [self.of]
283
284    def getGraphQLInputType(self):
285        return self.of.getGraphQLInputType()
286
287
288def ramlMaybeNoneOrList(k, v):
289    if isinstance(v, NoneOk):
290        return k + "?"
291    if isinstance(v, List):
292        return k + "[]"
293    return k
294
295
296class SourcedProperties(Type):
297
298    name = "sourcedproperties"
299
300    def validate(self, name, object):
301        if not isinstance(object, dict):  # we want a dict, and NOT a subclass
302            yield "{} is not sourced properties (not a dict)".format(name)
303            return
304        for k, v in object.items():
305            if not isinstance(k, str):
306                yield "{} property name {} is not unicode".format(name, repr(k))
307            if not isinstance(v, tuple) or len(v) != 2:
308                yield "{} property value for '{}' is not a 2-tuple".format(name, k)
309                return
310            propval, propsrc = v
311            if not isinstance(propsrc, str):
312                yield "{}[{}] source {} is not unicode".format(name, k, repr(propsrc))
313            try:
314                json.loads(bytes2unicode(propval))
315            except ValueError:
316                yield "{}[{}] value is not JSON-able".format(name, repr(k))
317
318    def toRaml(self):
319        return {'type': "object",
320                'properties':
321                {'[]': {'type': 'object',
322                        'properties': {
323                            1: 'string',
324                            2: 'integer | string | object | array | boolean'
325                        }
326                        }}}
327
328    def toGraphQL(self):
329        return "[Property]!"
330
331    def graphQLDependentTypes(self):
332        return [PropertyEntityType("property", 'Property')]
333
334    def getGraphQLInputType(self):
335        return None
336
337
338class JsonObject(Type):
339    name = "jsonobject"
340    ramlname = 'object'
341    graphQLType = "JSON"
342
343    def validate(self, name, object):
344        if not isinstance(object, dict):
345            yield "{} ({}) is not a dictionary (got type {})".format(name, repr(object),
346                                                                     type(object))
347            return
348
349        # make sure JSON can represent it
350        try:
351            json.dumps(object)
352        except Exception as e:
353            yield "{} is not JSON-able: {}".format(name, e)
354            return
355
356    def toRaml(self):
357        return "object"
358
359
360class Entity(Type):
361
362    # NOTE: this type is defined by subclassing it in each resource type class.
363    # Instances are generally accessed at e.g.,
364    #  * buildsets.Buildset.entityType or
365    #  * self.master.data.rtypes.buildsets.entityType
366
367    name = None  # set in constructor
368    graphql_name = None  # set in constructor
369    fields = {}
370    fieldNames = set([])
371
372    def __init__(self, name, graphql_name):
373        fields = {}
374        for k, v in self.__class__.__dict__.items():
375            if isinstance(v, Type):
376                fields[k] = v
377        self.fields = fields
378        self.fieldNames = set(fields)
379        self.name = name
380        self.graphql_name = graphql_name
381
382    def validate(self, name, object):
383        # this uses isinstance, allowing dict subclasses as used by the DB API
384        if not isinstance(object, dict):
385            yield "{} ({}) is not a dictionary (got type {})".format(name, repr(object),
386                                                                     type(object))
387            return
388
389        gotNames = set(object.keys())
390
391        unexpected = gotNames - self.fieldNames
392        if unexpected:
393            yield "{} has unexpected keys {}".format(name, ", ".join([repr(n) for n in unexpected]))
394
395        missing = self.fieldNames - gotNames
396        if missing:
397            yield "{} is missing keys {}".format(name, ", ".join([repr(n) for n in missing]))
398
399        for k in gotNames & self.fieldNames:
400            f = self.fields[k]
401            for msg in f.validate("{}[{}]".format(name, repr(k)), object[k]):
402                yield msg
403
404    def getSpec(self):
405        return dict(type=self.name,
406                    fields=[dict(name=k,
407                                 type=v.name,
408                                 type_spec=v.getSpec())
409                            for k, v in self.fields.items()
410                            ])
411
412    def toRaml(self):
413        return {'type': "object",
414                'properties': {
415                    ramlMaybeNoneOrList(k, v): {'type': v.ramlname, 'description': ''}
416                    for k, v in self.fields.items()}}
417
418    def toGraphQL(self):
419        return dict(type=self.graphql_name,
420                    fields=[dict(name=k,
421                                 type=v.toGraphQL())
422                            for k, v in self.fields.items()
423                            # in graphql, we handle properties as queriable sub resources
424                            # instead of hardcoded attributes like in rest api
425                            if k != "properties"
426                            ])
427
428    def toGraphQLTypeName(self):
429        return self.graphql_name
430
431    def graphQLDependentTypes(self):
432        return self.fields.values()
433
434    def getGraphQLInputType(self):
435        # for now, complex types are not query able
436        # in the future, we may want to declare (and implement) graphql input types
437        return None
438
439
440class PropertyEntityType(Entity):
441    name = String()
442    source = String()
443    value = JsonObject()
444