Przeglądaj źródła

Continue working on project, start working on API.

Cixo Develop 7 miesięcy temu
rodzic
commit
e25c25f6cf

+ 7 - 2
assets/__init__.py

@@ -2,7 +2,6 @@ from .password import password
 from .user import user
 from .user import user_builder
 from .user_loader import user_loader
-from .database import database
 from .apikey import apikey
 from .secret import secret
 from .secret import secret_builder
@@ -14,4 +13,10 @@ from .code_key import code_key
 from .code_key import code_key_generator
 from .code_key import code_key_manager
 from .settings import settings
-from .application import application
+from .application_part import application_part
+from .application_user import application_user
+from .validators import validator
+from .validators import validator_dumper
+from .validators import validator_result
+from .validators import password_validator
+from .validators import nick_validator

+ 0 - 46
assets/application.py

@@ -1,46 +0,0 @@
-import sqlmodel
-import sqlalchemy.engine.base
-
-from .settings import settings
-from .user import user
-from .user import user_builder
-from .user_loader import user_loader
-
-class application:
-    def __init__(self, config: settings) -> None:
-        self.__config = config
-        self.__init_database(config.database)
-
-    def get_provider(self) -> dict:
-        return {
-            "app_name": self.config.app_name,
-            "description": self.config.app_description,
-            "organization_name": self.config.organization_name
-        }
-
-    def post_register(self, nick: str, password: str) -> dict:
-        builder = user_builder()
-        builder.nick = nick
-        builder.set_password(password)
-
-        with user_loader(self.database) as loader:
-            if loader.register(builder.result):
-                return {
-                    "status": True
-                }
-
-            return {
-                "status": False
-            }
-
-    @property
-    def config(self) -> settings:
-        return self.__config
-
-    @property
-    def database(self) -> sqlalchemy.engine.base.Engine:
-        return self.__database
-
-    def __init_database(self, url: str) -> None:
-        self.__database = sqlmodel.create_engine(url)
-        sqlmodel.SQLModel.metadata.create_all(self.__database)

+ 88 - 0
assets/application_part.py

@@ -0,0 +1,88 @@
+import sqlmodel
+import sqlalchemy.engine.base
+
+from .validators import validator
+from .validators import validator_result
+
+class application_part:
+    """
+    This class is parent for parts of the applications. It define responses
+    and method for access to database.
+    """
+
+    def __init__(self, database: sqlalchemy.engine.base.Engine) -> None:
+        """
+        This initialize part of application, database connection is required
+        to have access to it in the application parts.
+        
+        Parameters:
+            database (Engine): Database connection
+        """
+
+        self.__connector = database
+
+    @property
+    def _connector(self) -> sqlalchemy.engine.base.Engine:
+        """ It return connection to database. """
+
+        return self.__connector
+
+    def _apikey_response(self, apikey: str) -> dict:
+        """ It return response with apikey. """
+
+        return self._success_response(apikey = apikey)
+
+    def _validation(self, name: str, target: validator) -> dict | None:
+        """
+        This help validating. It require name of the validation, and 
+        validator. Then validate, and if validator is fine, return None
+        or when something is bad, then return response.
+
+        Parameters:
+            name (str): Name of the validation
+            target (validator): Validator to check
+
+        Returns:
+            (dict | None): Response with error or None on success
+        """
+
+        result = target.result
+
+        if result == validator_result.valid:
+            return None
+
+        return self._fail_response(
+            code = int(result),
+            description = validator_result.name(result),
+            validating = name
+        )
+
+    def _success_response(self, **kwargs) -> dict:
+        """ It returns success response, with additional params. """
+        
+        base = dict()
+        base.update(kwargs)
+        base["status"] = "success"
+
+        return base
+
+    def _fail_no_apikey(self) -> dict:
+        """ This return error response for not founded ApiKey. """
+
+        return self._fail_response(cause = "ApiKey not exists.")
+
+    def _fail_bad_password(self) -> dict:
+        """ This return error response to use when bad password provided. """
+
+        return self._fail_response(cause = "Password validation incorrect.")
+
+    def _fail_response(self, **kwargs) -> dict:
+        """ It return fail response, with additions content. """
+
+        base = dict()
+        base.update(kwargs)
+        base["status"] = "fail"
+
+        return base
+
+    

+ 245 - 0
assets/application_user.py

@@ -0,0 +1,245 @@
+from .user import user
+from .user import user_builder
+from .user_loader import user_loader
+from .application_part import application_part
+from .validators import nick_validator
+from .validators import password_validator
+
+class application_user(application_part):
+    """
+    This class is part of the app, which is responsible for user api 
+    endpoints, like registering users, login to app, change nick and
+    others. To full responses and parameters documentation, see api
+    documentation.
+    """
+
+    def get(self, apikey: str) -> dict:
+        """
+        This return information about user, like nick or code_key, to use
+        in end-to-end encryption.
+
+        Parameters:
+            apikey (str): Apikey of the user
+
+        Returns:
+            (dict): Information about user
+        """
+
+        with self.__loader as loader:
+            user = loader.get_by_apikey(apikey)
+
+            if user is None:
+                return self._fail_no_apikey()
+
+            return self._success_response(
+                nick = user.nick,
+                password = user.password,
+                apikey = user.apikey,
+                code_key = user.code_key
+            )
+
+    def register(self, nick: str, password: str) -> dict:
+        """
+        This register new user, by password and nick. It check that nick is
+        not in use, also validate password and nick. ApiKey and code_key 
+        would be generate by operating system random generator.
+
+        Parameters:
+            nick (str): Nick of the new user
+            password (str): Password of the new user
+
+        Returns:
+            (dict): Response to generate json
+        """
+
+        validate = self._validation(
+            "nick", 
+            nick_validator(nick)
+        )
+
+        validate = validate or self._validation(
+            "password", 
+            password_validator(password)
+        )
+
+        if validate is not None:
+            return validate
+
+        builder = user_builder()
+        builder.nick = nick
+        builder.set_password(password)
+
+        with self.__loader as loader:
+            new_user = builder.result
+
+            if loader.nick_in_use(new_user.nick):
+                return self._fail_response(cause = "Nick already in use.")
+
+            if loader.register(new_user):
+                return self._apikey_response(new_user.apikey)
+
+            return self._fail_response(cause = "Other database error.")
+
+    def login(self, nick: str, password: str) -> dict:
+        """
+        This function login user. To operate client apps require apikey. This 
+        endpoint would return apikey, when valid nick and password had been
+        provided.
+
+        Parameters:
+            nick (str): Nick of the user
+            password (str): Passworrd of the user
+
+        Returns:
+            (dict): Result with apikey or error on fail
+        """
+
+        with self.__loader as loader:
+            user = loader.login(nick, password)
+
+            if user is None:
+                return self._fail_response(cause = "Bad login or password.")
+
+            return self._apikey_response(user.apikey)
+
+    def unregister(self, apikey: str, password: str) -> dict:
+        """
+        This function drop user from database. It require password as second
+        validation, and of course apikey to identify user.
+
+        Parameters:
+            apikey (str): ApiKey of the user to drop
+            password (str): Password of the user to drop
+
+        Returns:
+            (dict): Result of the operation as to generate json response
+        """
+
+        with self.__loader as loader:
+            user = loader.get_by_apikey(apikey)
+
+            if user is None:
+                return self._fail_no_apikey()
+
+            if not user_builder(user).check_password(password):
+                return self._fail_bad_password()
+
+            loader.unregister(user)
+
+            return self._success_response()
+
+    def apikey_refresh(self, apikey: str) -> dict:
+        """
+        This function refresh apikey. It is useable when want to logout all
+        devices which store apikey to stay logged in.
+
+        Parameters:
+            apikey (str): ApiKey of the user to refresh apikey
+
+        Returns:
+            (dict): Result of the operation, with new apikey when success
+        """
+
+        with self.__loader as loader:
+            user = loader.get_by_apikey(apikey)
+
+            if user is None:
+                return self._fail_no_apikey()
+
+            builder = user_builder(user)
+            builder.refresh_apikey()
+            new_user = builder.result
+
+            if not loader.save(new_user):
+                return self._fail_response(cause = "Database error.")
+
+            return self._apikey_response(new_user.apikey)
+
+    def change_password(
+        self,
+        apikey: str,
+        old_password: str,
+        new_password: str
+    ) -> dict:
+        """
+        This function change password of the user. It require also old 
+        password, to decrypt key, which would be re-encrypt by new password.
+        Of course new password would be validated before set.
+
+        Parameters:
+            apikey (str): ApiKey of the user to work on
+            old_password (str): Old password of the user
+            new_password (str): New password which would be set
+
+        Returns:
+            (dict): Result of the operation to create response
+        """
+
+        validate = self._validation(
+            "password", 
+            password_validator(new_password)
+        )
+
+        if validate is not None:
+            return validate
+
+        with self.__loader as loader:
+            user = loader.get_by_apikey(apikey)
+
+            if user is None:
+                return self._fail_no_apikey()
+
+            builder = user_builder(user)
+
+            if not builder.set_password(new_password, old_password):
+                return self._fail_bad_password()
+
+            new_user = builder.result
+            loader.save(new_user)
+
+            return self._apikey_response(new_user.apikey)
+
+    def change_nick(self, apikey: str, nick: str) -> dict:
+        """
+        This function would change nick of the user. It also check that nick
+        is not in use, and validate that nick is not bad formated.
+
+        Parameters:
+            apikey (str): ApiKey of the user to work on
+            nick (str): New nick for the user
+
+        Returns:
+            (dict): Result of the operation to create response
+        """
+
+        validation = self._validation(
+            "nick",
+            nick_validator(nick)
+        )
+
+        if validation is not None:
+            return validation
+
+        with self.__loader as loader:
+            user = loader.get_by_apikey(apikey)
+
+            if user is None:
+                return self._fail_no_apikey()
+
+            if loader.nick_in_use(nick):
+                return self._fail_response(cause = "Nick already in use.")
+
+            builder = user_builder(user)
+            builder.nick = nick
+            new_user = builder.result
+
+            if not loader.save(new_user):
+                return self._fail_response(cause = "Other database error.")
+
+            return self._success_response()
+
+    @property
+    def __loader(self) -> user_loader:
+        """ This return new user_loader with database connector. """
+
+        return user_loader(self._connector)

+ 0 - 4
assets/database.py

@@ -1,4 +0,0 @@
-from .user import user
-
-class database:
-    pass

+ 48 - 10
assets/user.py

@@ -5,6 +5,7 @@ from .password import password
 from .builder import builder
 from .code_key import code_key
 from .code_key import code_key_manager
+from .secret_coder import bad_password
 
 class user(sqlmodel.SQLModel, table = True):
     """
@@ -99,6 +100,16 @@ class user_builder(builder, target_type = user):
         """
 
         super().__init__(target)
+
+        if target is None:
+            self.refresh_apikey()
+
+    def refresh_apikey(self) -> None:
+        """
+        This function refresh apikey of the user. It could be useable to
+        logout all of the devices which use user account.
+        """
+
         self._target.apikey = apikey()
 
     @property
@@ -125,11 +136,26 @@ class user_builder(builder, target_type = user):
 
         self._target.nick = target.upper()
 
+    def check_password(self, target: str) -> bool:
+        """
+        This function check that given password is correct with current user
+        password. It is usefull when trying to have second factor, password + 
+        ApiKey.
+
+        Parameters:
+            target (str): Password to check
+
+        Returns:
+            (bool): True when given password is correct, False when not
+        """
+
+        return password(target).validate(self._target.password)
+
     def set_password(
         self, 
         password: str, 
         old_password: str | None = None
-    ) -> None:
+    ) -> bool:
         """
         This function set password to user. When only password is given, then 
         it try to init user, which not have password and crypto key yet. User
@@ -139,13 +165,16 @@ class user_builder(builder, target_type = user):
         Parameters:
             password (str): New password to set
             old_password (str | None) = None: Old password, require to recrypt
+        
+        Returns:
+            (bool): True when changed success, False when old password is bad
         """
 
         if old_password is None:
             self.__init_password(password)
-            return
+            return True
 
-        self.__change_password(old_password, password)
+        return self.__change_password(old_password, password)
 
     def __init_password(self, target: str) -> None:
         """
@@ -167,7 +196,7 @@ class user_builder(builder, target_type = user):
         self._target.password = password(target).result
         self._target.code_key = code_key(password = target)
 
-    def __change_password(self, old_password: str, new_password: str) -> None:
+    def __change_password(self, old_password: str, new_password: str) -> bool:
         """
         This change password, when user already have password and code key. It
         recrypt crypto key, to could use new password for secret decrypting.
@@ -175,15 +204,24 @@ class user_builder(builder, target_type = user):
         Parameters:
             old_password (str): Old password
             new_password (str): New password to set
+        
+        Returns:
+            (bool): True when changed success, False when old password is bad
         """
 
         if old_password == new_password:
-            raise Exception("New password is same as old password.")
+            return True
+
+        try:
+            key = self._target.key(old_password)
 
-        key = self._target.key(old_password)
+            if key is None or self._target.password is None:
+                raise Exception("User crypto key is not initialized yet.")
 
-        if key is None or self._target.password is None:
-            raise Exception("User crypto key is not initialized yet.")
+            self._target.code_key = key.recrypt(new_password).encrypted
+            self._target.password = password(new_password).result
 
-        self._target.code_key = key.recrypt(new_password).encrypted
-        self._target.password = password(new_password).result
+            return True
+
+        except bad_password:
+            return False

+ 8 - 3
assets/user_loader.py

@@ -79,7 +79,11 @@ class user_loader(sqlmodel.Session):
         return: bool - True when saved successfull, False when failed
         """
 
-        if not self.is_registered(user):
+        try:
+            if not self.is_registered(target):
+                return False
+
+        except:
             return False
 
         self.add(target)
@@ -96,7 +100,7 @@ class user_loader(sqlmodel.Session):
         return: bool - True when nick in use, False if not
         """
 
-        return self.get_by_nick(nick.upper()) is not None
+        return self.get_by_nick(nick) is not None
 
     def get_by_apikey(self, apikey: str) -> user | None:
         """
@@ -129,6 +133,7 @@ class user_loader(sqlmodel.Session):
         return: user | None - Loaded user or None when not exists.
         """
 
+        nick = nick.upper()
         query = sqlmodel.select(user).where(user.nick == nick).limit(1)
         result = self.exec(query)
 
@@ -155,7 +160,7 @@ class user_loader(sqlmodel.Session):
         return: bool - True when user is registered, False when not
         """
 
-        if target.id is None:
+        if not target.in_database:
             return False
 
         return self.get_by_id(target.id) is not None

+ 286 - 0
assets/validators.py

@@ -0,0 +1,286 @@
+import re
+import enum
+
+class validator_result(enum.IntEnum):
+    """
+    This is class, which provide validator_results. Validator results is 
+    presents as int, and this class define which int represent which error.
+    """
+
+    """ Valid result. """    
+    valid = 0
+
+    """ Given string is too long. """
+    too_long = 1
+
+    """ Given string is too short. """
+    too_short = 2
+
+    """ Given string not contain one of required chars. """
+    add_required_char = 3
+
+    """ Given string contain one of blocked chars. """
+    contain_invalid_char = 4
+
+    def name(code: int) -> str | None:
+        """
+        This function convert given error code to readable string. When given
+        code means, that string is valid, return None.
+
+        Parameters:
+            code (int): Error code number
+
+        Returns:
+            (str | None): Error as string, None when code is valid
+        """
+
+        if code == validator_result.valid:
+            return None
+
+        if code == validator_result.too_long:
+            return "Given string is too long."
+
+        if code == validator_result.too_short:
+            return "Given string is too short."
+
+        if code == validator_result.add_required_char:
+            return "Given string not contain one of required chars."
+
+        if code == validator_result.contain_invalid_char:
+            return "Given string contain char, which is blocker."
+
+        return "Not known error number."
+
+class validator:
+    """
+    This is validator class. It is responsible for checking, that given string
+    meets the organisation policy. For example, password is not too short, or
+    contain required string.
+    """
+
+    def __init__(self, content: str | None = None) -> None:
+        """
+        This is class initialiser, it get string to check. It also could get
+        None, then information validator is builded. Information validator is
+        validator, which not validate anything, but provide information about
+        validation, like max_lenght.
+
+        Parameters:
+            content (str | None): Target string to check, or None
+        """
+
+        self.__content = content
+
+    @property
+    def content(self) -> str:
+        """ 
+        String to check, provided in the constructor. When content is None,
+        it raise TypeError.
+
+        Returns:
+            (str): Content of the validator
+        """
+
+        if self.__content is None:
+            error = "This is only validator, which provide informations. "
+            error = error + "It has not any content."
+            raise TypeError(error)
+
+        return self.__content
+
+    @property
+    def is_valid(self) -> bool:
+        """
+        This check that provided string is valid.
+
+        Returns:
+            (bool): True when provided string is valid, False if not
+        """
+
+        return self.result == validator_result.valid
+
+    @property
+    def result(self) -> int:
+        """
+        This return result of the check, as number from validator_result.
+
+        Returns:
+            (int): Result of the validation.
+        """
+        
+        lenght = len(self.content)
+        invalid_regex = False
+        contain_regex = False
+
+        if len(self.must_contain) > 0:
+            contain_regex = "[" + "".join(self.must_contain) + "]"
+        
+        if len(self.invalid_chars) > 0:
+            invalid_regex = "[" + "".join(self.invalid_chars) + "]"
+
+        if lenght > self.max_lenght:
+            return validator_result.too_long
+
+        if lenght < self.min_lenght:
+            return validator_result.too_short
+
+        if invalid_regex and len(re.findall(invalid_regex, self.content)) > 0:
+            return validator_result.contain_invalid_char
+
+        if contain_regex and len(re.findall(contain_regex, self.content)) == 0:
+            return validator_result.add_required_char
+
+        return self._end_final(self.content)
+
+    def _end_final(self, content: str) -> int:
+        """
+        This function is end check. It could be overwriten to make custom 
+        validation. For example check for additional reguls.
+
+        Parameters:
+            content (str): Content of the string, which must be validate
+
+        Returns:
+            (int): Number from validator_results enum
+        """
+
+        return validator_result.valid
+
+    @property        
+    def info(self) -> str | None:
+        """ This return additional info about validator, for frontend. """
+
+        return None
+
+    @property
+    def min_lenght(self) -> int:
+        """ This return minimum lenght of the string. """
+
+        raise TypeError("Property min_lenght must be overwrite.")
+
+    @property
+    def max_lenght(self) -> int:
+        """ This return maximum lenght of the string. """
+
+        raise TypeError("Property max_lenght must be overwrite.")
+
+    @property
+    def must_contain(self) -> list:
+        """ This return list of chars, one of them must being in content """
+
+        raise TypeError("Property must_contain must be overwrite.")
+
+    @property
+    def invalid_chars(self) -> list:
+        """ This return chars, which can not being in the content. """
+
+        raise TypeError("Property invalid_chars must be overwrite.")
+
+class validator_dumper:
+    """
+    This class is responsible for validators info dumps, required for example
+    on application frontend, to presents information.
+    """
+
+    def __init__(self, target: type) -> None:
+        """
+        This set target validator.
+
+        Parameters:
+            target (type): This is validator to setup, as class
+        """
+
+        self.__target = target()
+
+    @property
+    def target(self) -> validator:
+        """ Target validator. """
+
+        return self.__target
+
+    @property
+    def route(self) -> dict:
+        """ 
+        This is dump of all informations as dictionary.
+         * max-lenght (int) -> validator.max_lenght 
+         * min-lenght (int) -> validator.min_lenght 
+         * invalid-chars (list) -> validator.invalid_chars
+         * required-chars (list) -> validator.must_contain
+         * readable-info (str) -> validator.info or ""
+
+        Returns:
+            (dict): Info as described up
+        """
+        return {
+            "max-lenght": self.target.max_lenght,
+            "min-lenght": self.target.min_lenght,
+            "invalid-chars": self.target.invalid_chars,
+            "required-chars": self.target.must_contain,
+            "readable-info": self.target.info or ""
+        }
+
+class password_validator(validator):
+    """
+    This is validator for main app password.
+    """
+
+    @property
+    def max_lenght(self) -> int:
+        return 256
+
+    @property
+    def must_contain(self) -> list:
+        return []
+
+    @property
+    def invalid_chars(self) -> list:
+        return ["\"", "'", " ", "\t", "\n", "`"]
+
+    @property
+    def info(self) -> str:
+        return "Password can not have white chars and quotation marks."
+
+    @property
+    def min_lenght(self) -> int:
+        return 8
+
+class nick_validator(validator):
+    """
+    This is validator for nick in app.
+    """
+
+    @property
+    def max_lenght(self) -> int:
+        return 256
+
+    @property
+    def must_contain(self) -> list:
+        return []
+
+    @property
+    def invalid_chars(self) -> list:
+        return []
+
+    @property
+    def min_lenght(self) -> int:
+        return 4
+
+    @property
+    def info(self) -> str | None:
+        return "Nick can contain only letters, digits, and \"-, _\" chars."
+
+    def _end_final(self, content: str) -> int:
+        for letter in content:
+            if letter.isalpha():
+                continue
+        
+            if letter.isdigit():
+                continue
+
+            if letter == "_" or letter == "-":
+                continue
+
+            return validator_result.contain_invalid_char
+
+        return validator_result.valid
+

+ 98 - 0
tests/006-application.py

@@ -0,0 +1,98 @@
+import pathlib
+
+current = pathlib.Path(__file__).parent
+root = current.parent
+
+import sys
+sys.path.append(str(root))
+
+import assets
+import sqlmodel
+
+def drop_db() -> None:
+    db = pathlib.Path("./006-application.db")
+
+    if db.is_file():
+        db.unlink()
+
+drop_db()
+
+connection = sqlmodel.create_engine("sqlite:///006-application.db")
+app = assets.application_user(connection)
+
+sqlmodel.SQLModel.metadata.create_all(connection)
+
+print("Register.")
+print("With success:")
+print(app.register("user1", "password"))
+print(app.register("user2", "password"))
+print("With fail:")
+print(app.register("user2", "password"))
+print(app.register("user3", "pas"))
+print(app.register("user3", "paswword\""))
+
+print()
+
+print("Login.")
+print("With success:")
+print(app.login("user1", "password"))
+print(app.login("user2", "password"))
+print("With fail:")
+print(app.login("user3", "password"))
+print(app.login("user1", "password_bad"))
+
+print()
+
+test_apikey = app.login("user1", "password")["apikey"]
+
+print("Get user.")
+print("With success:")
+print(app.get(test_apikey))
+print("With fail:")
+print(app.get("not exists"))
+
+print("Unregister.")
+print("Registering new user...")
+
+to_drop_apikey = app.register("user_to_drop", "password1")["apikey"]
+
+print("With fail:")
+print(app.unregister("jeriojeroi", "password1"))
+print(app.unregister(to_drop_apikey, "bad_password"))
+print("With success:")
+print(app.unregister(to_drop_apikey, "password1"))
+
+print()
+
+print("Apikey refresh.")
+print("With success:")
+print(app.apikey_refresh(test_apikey))
+print("With fail (old apikey):")
+print(app.apikey_refresh(test_apikey))
+
+test_apikey = app.login("user1", "password")["apikey"]
+
+print()
+
+print("Change password.")
+print("With success:")
+print(app.change_password(test_apikey, "password", "password1"))
+print(app.change_password(test_apikey, "password1", "password"))
+print("With fail:")
+print(app.change_password("fjljsdkl", "password1", "password"))
+print(app.change_password(test_apikey, "password1", "password"))
+print(app.change_password(test_apikey, "password1", "password\'"))
+
+print()
+
+print("Change nick.")
+print("With success:")
+print(app.change_nick(test_apikey, "test_user"))
+print("Result:")
+print(app.get(test_apikey))
+print("With fail:")
+print(app.change_nick(test_apikey, "SAMpl\'"))
+print(app.change_nick("jjsfdfjskl", "nick1"))
+print(app.change_nick(test_apikey, "user2"))
+
+drop_db()

+ 26 - 0
tests/007-validator.py

@@ -0,0 +1,26 @@
+import pathlib
+
+current = pathlib.Path(__file__).parent
+root = current.parent
+
+import sys
+sys.path.append(str(root))
+
+import assets
+
+def check(validator: assets.validator) -> None:
+    if validator.is_valid:
+        print("Validator for: \"" + validator.content + "\" is valid.")
+        return 
+
+    result = str(validator.result) + " \""
+    result = result + assets.validator_result.name(validator.result) + "\""
+
+    print("Validator for \"" + validator.content + "\" return " + result + ".")
+
+check(assets.password_validator("OwO"))
+check(assets.password_validator("OwOOwOWWOwO"))
+check(assets.password_validator("OwOOwOW'WOwO"))
+
+print("Dump dict of validator info for route: ")
+print(assets.validator_dumper(assets.password_validator).route) 

+ 1 - 0
tests/config_sample.json

@@ -0,0 +1 @@
+{}