first try at api using flask-restx & marshmallow
							parent
							
								
									c30944404d
								
							
						
					
					
						commit
						866ad89dfc
					
				| @ -0,0 +1,38 @@ | ||||
| from flask import redirect, url_for | ||||
| 
 | ||||
| # import api version(s) | ||||
| from . import v1 | ||||
| 
 | ||||
| # api | ||||
| ROOT='/api' | ||||
| ACTIVE=v1 | ||||
| 
 | ||||
| # patch url for swaggerui static assets | ||||
| from flask_restx.apidoc import apidoc | ||||
| apidoc.static_url_path = f'{ROOT}/swaggerui' | ||||
| 
 | ||||
| def register(app): | ||||
| 
 | ||||
|     # register api bluprint(s) | ||||
|     app.register_blueprint(v1.blueprint, url_prefix=f'{ROOT}/v{int(v1.VERSION)}') | ||||
| 
 | ||||
|     # add redirect to current api version | ||||
|     @app.route(f'{ROOT}/') | ||||
|     def redir(): | ||||
|         return redirect(url_for(f'{ACTIVE.blueprint.name}.root')) | ||||
| 
 | ||||
|     # swagger ui config | ||||
|     app.config.SWAGGER_UI_DOC_EXPANSION = 'list' | ||||
|     app.config.SWAGGER_UI_OPERATION_ID = True | ||||
|     app.config.SWAGGER_UI_REQUEST_DURATION = True | ||||
| 
 | ||||
|     # TODO: remove patch of static assets for debugging | ||||
|     import os | ||||
|     if 'STATIC_ASSETS' in os.environ: | ||||
|         app.blueprints['ui'].static_folder = os.environ['STATIC_ASSETS'] | ||||
| 
 | ||||
| # TODO: authentication via username + password | ||||
| # TODO: authentication via api token | ||||
| # TODO: api access for all users (via token) | ||||
| # TODO: use permissions from "manager_of" | ||||
| # TODO: switch to marshmallow, as parser is deprecated. use flask_accepts? | ||||
| @ -0,0 +1,8 @@ | ||||
| from .. import models | ||||
| 
 | ||||
| def fqdn_in_use(*names): | ||||
|     for name in names: | ||||
|         for model in models.Domain, models.Alternative, models.Relay: | ||||
|             if model.query.get(name): | ||||
|                 return model | ||||
|     return None | ||||
| @ -0,0 +1,27 @@ | ||||
| from flask import Blueprint | ||||
| from flask_restx import Api, fields | ||||
| 
 | ||||
| VERSION = 1.0 | ||||
| 
 | ||||
| blueprint = Blueprint(f'api_v{int(VERSION)}', __name__) | ||||
| 
 | ||||
| api = Api( | ||||
|     blueprint, version=f'{VERSION:.1f}', | ||||
|     title='Mailu API', default_label='Mailu', | ||||
|     validate=True | ||||
| ) | ||||
| 
 | ||||
| response_fields = api.model('Response', { | ||||
|     'code': fields.Integer, | ||||
|     'message': fields.String, | ||||
| }) | ||||
| 
 | ||||
| error_fields = api.model('Error', { | ||||
|     'errors': fields.Nested(api.model('Error_Key', { | ||||
|         'key': fields.String, | ||||
|         'message':fields.String | ||||
|     })), | ||||
|     'message': fields.String, | ||||
| }) | ||||
| 
 | ||||
| from . import domains | ||||
| @ -0,0 +1,183 @@ | ||||
| from flask_restx import Resource, fields, abort | ||||
| 
 | ||||
| from . import api, response_fields, error_fields | ||||
| from .. import common | ||||
| from ... import models | ||||
| 
 | ||||
| db = models.db | ||||
| 
 | ||||
| dom = api.namespace('domain', description='Domain operations') | ||||
| alt = api.namespace('alternative', description='Alternative operations') | ||||
| 
 | ||||
| domain_fields = api.model('Domain', { | ||||
|     'name': fields.String(description='FQDN', example='example.com', required=True), | ||||
|     'comment': fields.String(description='a comment'), | ||||
|     'max_users': fields.Integer(description='maximum number of users', min=-1, default=-1), | ||||
|     'max_aliases': fields.Integer(description='maximum number of aliases', min=-1, default=-1), | ||||
|     'max_quota_bytes': fields.Integer(description='maximum quota for mailbox', min=0), | ||||
|     'signup_enabled': fields.Boolean(description='allow signup'), | ||||
| #    'dkim_key': fields.String, | ||||
|     'alternatives': fields.List(fields.String(attribute='name', description='FQDN', example='example.com')), | ||||
| }) | ||||
| # TODO - name ist required on creation but immutable on change | ||||
| # TODO - name and alteranatives need to be checked to be a fqdn (regex) | ||||
| 
 | ||||
| domain_parser = api.parser() | ||||
| domain_parser.add_argument('max_users', type=int, help='maximum number of users') | ||||
| # TODO ... add more (or use marshmallow) | ||||
| 
 | ||||
| alternative_fields = api.model('Domain', { | ||||
|     'name': fields.String(description='alternative FQDN', example='example.com', required=True), | ||||
|     'domain': fields.String(description='domain FQDN', example='example.com', required=True), | ||||
|     'dkim_key': fields.String, | ||||
| }) | ||||
| # TODO: domain and name are not always required and can't be changed | ||||
| 
 | ||||
| 
 | ||||
| @dom.route('') | ||||
| class Domains(Resource): | ||||
| 
 | ||||
|     @dom.doc('list_domain') | ||||
|     @dom.marshal_with(domain_fields, as_list=True, skip_none=True, mask=['dkim_key']) | ||||
|     def get(self): | ||||
|         """ List domains """ | ||||
|         return models.Domain.query.all() | ||||
| 
 | ||||
|     @dom.doc('create_domain') | ||||
|     @dom.expect(domain_fields) | ||||
|     @dom.response(200, 'Success', response_fields) | ||||
|     @dom.response(400, 'Input validation exception', error_fields) | ||||
|     @dom.response(409, 'Duplicate domain name', error_fields) | ||||
|     def post(self): | ||||
|         """ Create a new domain """ | ||||
|         data = api.payload | ||||
|         if common.fqdn_in_use(data['name']): | ||||
|             abort(409, f'Duplicate domain name {data["name"]!r}', errors={ | ||||
|                 'name': data['name'], | ||||
|             }) | ||||
|         for item, created in models.Domain.from_dict(data): | ||||
|             if not created: | ||||
|                 abort(409, f'Duplicate domain name {item.name!r}', errors={ | ||||
|                         'alternatives': item.name, | ||||
|                     }) | ||||
|             db.session.add(item) | ||||
|         db.session.commit() | ||||
| 
 | ||||
| @dom.route('/<name>') | ||||
| class Domain(Resource): | ||||
| 
 | ||||
|     @dom.doc('get_domain') | ||||
|     @dom.response(200, 'Success', domain_fields) | ||||
|     @dom.response(404, 'Domain not found') | ||||
|     @dom.marshal_with(domain_fields) | ||||
|     def get(self, name): | ||||
|         """ Find domain by name """ | ||||
|         domain = models.Domain.query.get(name) | ||||
|         if not domain: | ||||
|             abort(404) | ||||
|         return domain | ||||
| 
 | ||||
|     @dom.doc('update_domain') | ||||
|     @dom.expect(domain_fields) | ||||
|     @dom.response(200, 'Success', response_fields) | ||||
|     @dom.response(400, 'Input validation exception', error_fields) | ||||
|     @dom.response(404, 'Domain not found') | ||||
|     def put(self, name): | ||||
|         """ Update an existing domain """ | ||||
|         domain = models.Domain.query.get(name) | ||||
|         if not domain: | ||||
|             abort(404) | ||||
|         data = api.payload | ||||
|         data['name'] = name | ||||
|         for item, created in models.Domain.from_dict(data): | ||||
|             if created is True: | ||||
|                 db.session.add(item) | ||||
|         db.session.commit() | ||||
| 
 | ||||
|     @dom.doc('modify_domain') | ||||
|     @dom.expect(domain_parser) | ||||
|     @dom.response(200, 'Success', response_fields) | ||||
|     @dom.response(400, 'Input validation exception', error_fields) | ||||
|     @dom.response(404, 'Domain not found') | ||||
|     def post(self, name=None): | ||||
|         """ Updates domain with form data """ | ||||
|         domain = models.Domain.query.get(name) | ||||
|         if not domain: | ||||
|             abort(404) | ||||
|         data = dict(domain_parser.parse_args()) | ||||
|         data['name'] = name | ||||
|         for item, created in models.Domain.from_dict(data): | ||||
|             if created is True: | ||||
|                 db.session.add(item) | ||||
|                 # TODO: flush? | ||||
|         db.session.commit() | ||||
| 
 | ||||
|     @dom.doc('delete_domain') | ||||
|     @dom.response(200, 'Success', response_fields) | ||||
|     @dom.response(404, 'Domain not found') | ||||
|     def delete(self, name=None): | ||||
|         """ Delete domain """ | ||||
|         domain = models.Domain.query.get(name) | ||||
|         if not domain: | ||||
|             abort(404) | ||||
|         db.session.delete(domain) | ||||
|         db.session.commit() | ||||
| 
 | ||||
| 
 | ||||
| # @dom.route('/<name>/alternative') | ||||
| # @alt.route('') | ||||
| # class Alternatives(Resource): | ||||
| 
 | ||||
| #     @alt.doc('alternatives_list') | ||||
| #     @alt.marshal_with(alternative_fields, as_list=True, skip_none=True, mask=['dkim_key']) | ||||
| #     def get(self, name=None): | ||||
| #         """ List alternatives (of domain) """ | ||||
| #         if name is None: | ||||
| #             return models.Alternative.query.all() | ||||
| #         else: | ||||
| #             return models.Alternative.query.filter_by(domain_name = name).all() | ||||
| 
 | ||||
| #     @alt.doc('alternative_create') | ||||
| #     @alt.expect(alternative_fields) | ||||
| #     @alt.response(200, 'Success', response_fields) | ||||
| #     @alt.response(400, 'Input validation exception', error_fields) | ||||
| #     @alt.response(404, 'Domain not found') | ||||
| #     @alt.response(409, 'Duplicate domain name', error_fields) | ||||
| #     def post(self, name=None): | ||||
| #         """ Create new alternative (for domain) """ | ||||
| #  #       abort(501) | ||||
| #         data = api.payload | ||||
| #         if name is not None: | ||||
| #             data['name'] = name | ||||
| #         domain = models.Domain.query.get(name) | ||||
| #         if not domain: | ||||
| #             abort(404) | ||||
| #         if common.fqdn_in_use(data['name']): | ||||
| #             abort(409, f'Duplicate domain name {data["name"]!r}', errors={ | ||||
| #                 'name': data['name'], | ||||
| #             }) | ||||
| #         for item, created in models.Alternative.from_dict(data): | ||||
| # # TODO: handle creation of domain | ||||
| #             if not created: | ||||
| #                 abort(409, f'Duplicate domain name {item.name!r}', errors={ | ||||
| #                         'alternatives': item.name, | ||||
| #                     }) | ||||
| #         #     db.session.add(item) | ||||
| #         # db.session.commit() | ||||
| 
 | ||||
| # @dom.route('/<name>/alternative/<alt>') | ||||
| # @alt.route('/<name>') | ||||
| # class Alternative(Resource): | ||||
| #     def get(self, name, alt=None): | ||||
| #         """ Find alternative (of domain) """ | ||||
| #         abort(501) | ||||
| #     def put(self, name, alt=None): | ||||
| #         """ Update alternative (of domain) """ | ||||
| #         abort(501) | ||||
| #     def post(self, name, alt=None): | ||||
| #         """ Update alternative (of domain) with form data """ | ||||
| #         abort(501) | ||||
| #     def delete(self, name, alt=None): | ||||
| #         """ Delete alternative (for domain) """ | ||||
| #         abort(501) | ||||
| 
 | ||||
					Loading…
					
					
				
		Reference in New Issue
	
	 Alexander Graf
						Alexander Graf