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