030-API服务开发

030-API服务开发

学习目标

通过本章学习,你将掌握:

  • RESTful API设计原则和最佳实践
  • GraphQL API实现
  • API版本控制策略
  • 认证和授权机制
  • API文档生成和管理
  • 限流和安全防护
  • API监控和分析

RESTful API设计

API设计原则

from flask import Flask, request, jsonify, g
from flask_restful import Api, Resource, reqparse, fields, marshal_with
from flask_jwt_extended import JWTManager, create_access_token, jwt_required, get_jwt_identity
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_cors import CORS
from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden, NotFound
import redis
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, asdict
from enum import Enum
import hashlib
import hmac
import time
import uuid
import logging
from functools import wraps

class APIVersion(Enum):
    """API版本枚举"""
    V1 = "v1"
    V2 = "v2"

class ResponseStatus(Enum):
    """响应状态枚举"""
    SUCCESS = "success"
    ERROR = "error"
    WARNING = "warning"

@dataclass
class APIResponse:
    """标准API响应格式"""
    status: ResponseStatus
    message: str
    data: Optional[Any] = None
    errors: Optional[List[str]] = None
    meta: Optional[Dict[str, Any]] = None
    timestamp: float = None
    request_id: str = None
    
    def __post_init__(self):
        if self.timestamp is None:
            self.timestamp = time.time()
        if self.request_id is None:
            self.request_id = str(uuid.uuid4())

@dataclass
class PaginationMeta:
    """分页元数据"""
    page: int
    per_page: int
    total: int
    pages: int
    has_next: bool
    has_prev: bool
    next_page: Optional[int] = None
    prev_page: Optional[int] = None

class APIKeyManager:
    """API密钥管理器"""
    
    def __init__(self, redis_client: redis.Redis):
        self.redis = redis_client
        self.key_prefix = "api_key:"
        self.usage_prefix = "api_usage:"
    
    def generate_api_key(self, user_id: str, name: str, 
                        rate_limit: int = 1000) -> str:
        """生成API密钥"""
        api_key = f"ak_{uuid.uuid4().hex}"
        
        key_data = {
            "user_id": user_id,
            "name": name,
            "rate_limit": rate_limit,
            "created_at": time.time(),
            "is_active": True,
            "last_used": None,
            "usage_count": 0
        }
        
        self.redis.hset(f"{self.key_prefix}{api_key}", mapping=key_data)
        return api_key
    
    def validate_api_key(self, api_key: str) -> Optional[Dict[str, Any]]:
        """验证API密钥"""
        key_data = self.redis.hgetall(f"{self.key_prefix}{api_key}")
        
        if not key_data:
            return None
        
        # 转换数据类型
        key_info = {
            "user_id": key_data[b"user_id"].decode(),
            "name": key_data[b"name"].decode(),
            "rate_limit": int(key_data[b"rate_limit"]),
            "created_at": float(key_data[b"created_at"]),
            "is_active": key_data[b"is_active"].decode() == "True",
            "last_used": float(key_data[b"last_used"]) if key_data[b"last_used"] != b"None" else None,
            "usage_count": int(key_data[b"usage_count"])
        }
        
        if not key_info["is_active"]:
            return None
        
        return key_info
    
    def update_usage(self, api_key: str):
        """更新使用统计"""
        pipe = self.redis.pipeline()
        pipe.hincrby(f"{self.key_prefix}{api_key}", "usage_count", 1)
        pipe.hset(f"{self.key_prefix}{api_key}", "last_used", time.time())
        
        # 更新每日使用统计
        today = datetime.now().strftime("%Y-%m-%d")
        pipe.hincrby(f"{self.usage_prefix}{api_key}:{today}", "requests", 1)
        pipe.expire(f"{self.usage_prefix}{api_key}:{today}", 86400 * 30)  # 保留30天
        
        pipe.execute()
    
    def check_rate_limit(self, api_key: str, window: int = 3600) -> bool:
        """检查速率限制"""
        key_info = self.validate_api_key(api_key)
        if not key_info:
            return False
        
        current_time = int(time.time())
        window_start = current_time - window
        
        # 使用滑动窗口计数
        usage_key = f"{self.usage_prefix}{api_key}:window"
        
        pipe = self.redis.pipeline()
        pipe.zremrangebyscore(usage_key, 0, window_start)
        pipe.zcard(usage_key)
        pipe.zadd(usage_key, {str(current_time): current_time})
        pipe.expire(usage_key, window)
        
        results = pipe.execute()
        current_usage = results[1]
        
        return current_usage < key_info["rate_limit"]

class ExifToolAPIService:
    """ExifTool API服务"""
    
    def __init__(self):
        self.app = Flask(__name__)
        self.app.config['JWT_SECRET_KEY'] = 'your-secret-key'  # 生产环境应使用环境变量
        self.app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(hours=24)
        
        # 初始化扩展
        self.api = Api(self.app)
        self.jwt = JWTManager(self.app)
        self.limiter = Limiter(
            app=self.app,
            key_func=get_remote_address,
            default_limits=["1000 per hour"]
        )
        CORS(self.app)
        
        # Redis连接
        self.redis = redis.Redis(host='localhost', port=6379, db=0)
        self.api_key_manager = APIKeyManager(self.redis)
        
        # 注册资源
        self._register_resources()
        
        # 注册错误处理器
        self._register_error_handlers()
        
        # 设置日志
        self._setup_logging()
    
    def _register_resources(self):
        """注册API资源"""
        # V1 API
        self.api.add_resource(MetadataExtractionV1, '/api/v1/metadata/extract')
        self.api.add_resource(MetadataSearchV1, '/api/v1/metadata/search')
        self.api.add_resource(FileProcessingV1, '/api/v1/files/process')
        self.api.add_resource(TaskStatusV1, '/api/v1/tasks/<string:task_id>')
        
        # V2 API
        self.api.add_resource(MetadataExtractionV2, '/api/v2/metadata/extract')
        self.api.add_resource(BatchProcessingV2, '/api/v2/batch/process')
        self.api.add_resource(AnalyticsV2, '/api/v2/analytics')
        
        # 认证相关
        self.api.add_resource(AuthLogin, '/api/auth/login')
        self.api.add_resource(AuthRefresh, '/api/auth/refresh')
        self.api.add_resource(APIKeyResource, '/api/auth/api-keys')
    
    def _register_error_handlers(self):
        """注册错误处理器"""
        
        @self.app.errorhandler(400)
        def bad_request(error):
            return self._create_error_response(
                "Bad Request", 
                [str(error.description)], 
                400
            )
        
        @self.app.errorhandler(401)
        def unauthorized(error):
            return self._create_error_response(
                "Unauthorized", 
                ["Authentication required"], 
                401
            )
        
        @self.app.errorhandler(403)
        def forbidden(error):
            return self._create_error_response(
                "Forbidden", 
                ["Insufficient permissions"], 
                403
            )
        
        @self.app.errorhandler(404)
        def not_found(error):
            return self._create_error_response(
                "Not Found", 
                ["Resource not found"], 
                404
            )
        
        @self.app.errorhandler(429)
        def rate_limit_exceeded(error):
            return self._create_error_response(
                "Rate Limit Exceeded", 
                ["Too many requests"], 
                429
            )
        
        @self.app.errorhandler(500)
        def internal_error(error):
            return self._create_error_response(
                "Internal Server Error", 
                ["An unexpected error occurred"], 
                500
            )
    
    def _create_error_response(self, message: str, errors: List[str], 
                             status_code: int) -> tuple:
        """创建错误响应"""
        response = APIResponse(
            status=ResponseStatus.ERROR,
            message=message,
            errors=errors
        )
        return jsonify(asdict(response)), status_code
    
    def _setup_logging(self):
        """设置日志"""
        logging.basicConfig(
            level=logging.INFO,
            format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
        )
        self.logger = logging.getLogger(__name__)

def require_api_key(f):
    """API密钥认证装饰器"""
    @wraps(f)
    def decorated_function(*args, **kwargs):
        api_key = request.headers.get('X-API-Key')
        if not api_key:
            raise Unauthorized("API key required")
        
        # 从Flask应用上下文获取API密钥管理器
        api_key_manager = g.get('api_key_manager')
        if not api_key_manager:
            from flask import current_app
            api_key_manager = current_app.api_key_manager
        
        key_info = api_key_manager.validate_api_key(api_key)
        if not key_info:
            raise Unauthorized("Invalid API key")
        
        # 检查速率限制
        if not api_key_manager.check_rate_limit(api_key):
            raise Forbidden("Rate limit exceeded")
        
        # 更新使用统计
        api_key_manager.update_usage(api_key)
        
        # 将用户信息添加到请求上下文
        g.current_user_id = key_info['user_id']
        g.api_key_info = key_info
        
        return f(*args, **kwargs)
    return decorated_function

class BaseResource(Resource):
    """基础资源类"""
    
    def __init__(self):
        self.parser = reqparse.RequestParser()
        self.setup_parser()
    
    def setup_parser(self):
        """设置请求解析器(子类重写)"""
        pass
    
    def create_response(self, data: Any = None, message: str = "Success", 
                      meta: Dict[str, Any] = None) -> Dict[str, Any]:
        """创建标准响应"""
        response = APIResponse(
            status=ResponseStatus.SUCCESS,
            message=message,
            data=data,
            meta=meta
        )
        return asdict(response)

class MetadataExtractionV1(BaseResource):
    """元数据提取API V1"""
    
    def setup_parser(self):
        self.parser.add_argument('file', type=str, required=True, 
                               help='File path or URL')
        self.parser.add_argument('format', type=str, default='json',
                               choices=['json', 'xml', 'csv'],
                               help='Output format')
        self.parser.add_argument('tags', type=str, action='append',
                               help='Specific tags to extract')
    
    @require_api_key
    @marshal_with({
        'status': fields.String,
        'message': fields.String,
        'data': fields.Raw,
        'timestamp': fields.Float,
        'request_id': fields.String
    })
    def post(self):
        """提取文件元数据"""
        args = self.parser.parse_args()
        
        try:
            from .exiftool_processor import ExifToolProcessor
            processor = ExifToolProcessor()
            
            # 提取元数据
            metadata = processor.extract_metadata(
                args['file'],
                tags=args.get('tags'),
                output_format=args['format']
            )
            
            return self.create_response(
                data={
                    'metadata': metadata,
                    'file': args['file'],
                    'format': args['format']
                },
                message="Metadata extracted successfully"
            )
            
        except Exception as e:
            raise BadRequest(f"Failed to extract metadata: {str(e)}")

class MetadataExtractionV2(BaseResource):
    """元数据提取API V2(增强版)"""
    
    def setup_parser(self):
        self.parser.add_argument('files', type=list, required=True,
                               help='List of file paths or URLs')
        self.parser.add_argument('options', type=dict, default={},
                               help='Extraction options')
        self.parser.add_argument('async_processing', type=bool, default=False,
                               help='Enable async processing')
        self.parser.add_argument('callback_url', type=str,
                               help='Callback URL for async processing')
    
    @require_api_key
    @limiter.limit("100 per hour")
    def post(self):
        """批量提取文件元数据"""
        args = self.parser.parse_args()
        
        if args['async_processing']:
            # 异步处理
            task_id = str(uuid.uuid4())
            
            # 提交到任务队列
            from .tasks import extract_metadata_batch
            extract_metadata_batch.delay(
                task_id=task_id,
                files=args['files'],
                options=args['options'],
                callback_url=args.get('callback_url'),
                user_id=g.current_user_id
            )
            
            return self.create_response(
                data={
                    'task_id': task_id,
                    'status': 'pending',
                    'estimated_completion': time.time() + len(args['files']) * 2
                },
                message="Batch processing started"
            )
        else:
            # 同步处理
            try:
                from .exiftool_processor import ExifToolProcessor
                processor = ExifToolProcessor()
                
                results = []
                for file_path in args['files']:
                    try:
                        metadata = processor.extract_metadata(
                            file_path, 
                            **args['options']
                        )
                        results.append({
                            'file': file_path,
                            'status': 'success',
                            'metadata': metadata
                        })
                    except Exception as e:
                        results.append({
                            'file': file_path,
                            'status': 'error',
                            'error': str(e)
                        })
                
                return self.create_response(
                    data={
                        'results': results,
                        'total_files': len(args['files']),
                        'successful': len([r for r in results if r['status'] == 'success']),
                        'failed': len([r for r in results if r['status'] == 'error'])
                    },
                    message="Batch processing completed"
                )
                
            except Exception as e:
                raise BadRequest(f"Batch processing failed: {str(e)}")

class MetadataSearchV1(BaseResource):
    """元数据搜索API"""
    
    def setup_parser(self):
        self.parser.add_argument('query', type=str, required=True,
                               help='Search query')
        self.parser.add_argument('filters', type=dict, default={},
                               help='Search filters')
        self.parser.add_argument('page', type=int, default=1,
                               help='Page number')
        self.parser.add_argument('per_page', type=int, default=20,
                               help='Items per page')
        self.parser.add_argument('sort_by', type=str, default='created_at',
                               help='Sort field')
        self.parser.add_argument('sort_order', type=str, default='desc',
                               choices=['asc', 'desc'],
                               help='Sort order')
    
    @require_api_key
    @limiter.limit("500 per hour")
    def get(self):
        """搜索元数据"""
        args = self.parser.parse_args()
        
        try:
            from .search_service import MetadataSearchService
            search_service = MetadataSearchService()
            
            # 执行搜索
            results, total = search_service.search(
                query=args['query'],
                filters=args['filters'],
                page=args['page'],
                per_page=args['per_page'],
                sort_by=args['sort_by'],
                sort_order=args['sort_order'],
                user_id=g.current_user_id
            )
            
            # 计算分页信息
            pages = (total + args['per_page'] - 1) // args['per_page']
            pagination = PaginationMeta(
                page=args['page'],
                per_page=args['per_page'],
                total=total,
                pages=pages,
                has_next=args['page'] < pages,
                has_prev=args['page'] > 1,
                next_page=args['page'] + 1 if args['page'] < pages else None,
                prev_page=args['page'] - 1 if args['page'] > 1 else None
            )
            
            return self.create_response(
                data={
                    'results': results,
                    'pagination': asdict(pagination)
                },
                message="Search completed successfully"
            )
            
        except Exception as e:
            raise BadRequest(f"Search failed: {str(e)}")

class AuthLogin(BaseResource):
    """用户登录API"""
    
    def setup_parser(self):
        self.parser.add_argument('username', type=str, required=True)
        self.parser.add_argument('password', type=str, required=True)
        self.parser.add_argument('remember_me', type=bool, default=False)
    
    def post(self):
        """用户登录"""
        args = self.parser.parse_args()
        
        # 验证用户凭据(这里应该连接到实际的用户数据库)
        if self._validate_credentials(args['username'], args['password']):
            # 创建访问令牌
            expires = timedelta(days=30) if args['remember_me'] else timedelta(hours=24)
            access_token = create_access_token(
                identity=args['username'],
                expires_delta=expires
            )
            
            return self.create_response(
                data={
                    'access_token': access_token,
                    'token_type': 'Bearer',
                    'expires_in': expires.total_seconds(),
                    'user': {
                        'username': args['username'],
                        'permissions': self._get_user_permissions(args['username'])
                    }
                },
                message="Login successful"
            )
        else:
            raise Unauthorized("Invalid credentials")
    
    def _validate_credentials(self, username: str, password: str) -> bool:
        """验证用户凭据"""
        # 这里应该实现实际的用户验证逻辑
        # 例如:查询数据库,验证密码哈希等
        return True  # 示例实现
    
    def _get_user_permissions(self, username: str) -> List[str]:
        """获取用户权限"""
        # 这里应该实现实际的权限查询逻辑
        return ['read', 'write']  # 示例实现

class APIKeyResource(BaseResource):
    """API密钥管理"""
    
    def setup_parser(self):
        self.parser.add_argument('name', type=str, required=True,
                               help='API key name')
        self.parser.add_argument('rate_limit', type=int, default=1000,
                               help='Rate limit per hour')
    
    @jwt_required()
    def post(self):
        """创建API密钥"""
        args = self.parser.parse_args()
        user_id = get_jwt_identity()
        
        from flask import current_app
        api_key = current_app.api_key_manager.generate_api_key(
            user_id=user_id,
            name=args['name'],
            rate_limit=args['rate_limit']
        )
        
        return self.create_response(
            data={
                'api_key': api_key,
                'name': args['name'],
                'rate_limit': args['rate_limit'],
                'created_at': time.time()
            },
            message="API key created successfully"
        )
    
    @jwt_required()
    def get(self):
        """获取用户的API密钥列表"""
        user_id = get_jwt_identity()
        
        # 这里应该实现获取用户API密钥列表的逻辑
        # 示例实现
        api_keys = [
            {
                'id': 'ak_example123',
                'name': 'Production Key',
                'rate_limit': 1000,
                'created_at': time.time() - 86400,
                'last_used': time.time() - 3600,
                'usage_count': 150
            }
        ]
        
        return self.create_response(
            data={'api_keys': api_keys},
            message="API keys retrieved successfully"
        )

GraphQL API实现

import graphene
from graphene import ObjectType, String, Int, Float, List, Field, Mutation, Schema
from graphene_file_upload.scalars import Upload
from flask import Flask
from flask_graphql import GraphQLView
import json
from typing import Optional, Dict, Any

class MetadataType(ObjectType):
    """元数据GraphQL类型"""
    file_name = String()
    file_size = Int()
    file_type = String()
    creation_date = String()
    modification_date = String()
    camera_make = String()
    camera_model = String()
    gps_latitude = Float()
    gps_longitude = Float()
    raw_data = String()  # JSON字符串

class FileType(ObjectType):
    """文件GraphQL类型"""
    id = String()
    path = String()
    name = String()
    size = Int()
    type = String()
    metadata = Field(MetadataType)
    created_at = String()
    updated_at = String()

class TaskType(ObjectType):
    """任务GraphQL类型"""
    id = String()
    status = String()
    progress = Float()
    result = String()  # JSON字符串
    error_message = String()
    created_at = String()
    updated_at = String()

class UserType(ObjectType):
    """用户GraphQL类型"""
    id = String()
    username = String()
    email = String()
    api_keys = List(String)
    created_at = String()

class Query(ObjectType):
    """GraphQL查询"""
    
    # 文件查询
    file = Field(FileType, id=String(required=True))
    files = List(FileType, 
                filter=String(), 
                limit=Int(default_value=20), 
                offset=Int(default_value=0))
    
    # 元数据查询
    metadata = Field(MetadataType, file_id=String(required=True))
    search_metadata = List(MetadataType, 
                          query=String(required=True),
                          filters=String())  # JSON字符串
    
    # 任务查询
    task = Field(TaskType, id=String(required=True))
    tasks = List(TaskType, 
                status=String(), 
                limit=Int(default_value=20),
                offset=Int(default_value=0))
    
    # 用户查询
    current_user = Field(UserType)
    
    def resolve_file(self, info, id):
        """解析单个文件"""
        # 这里应该实现实际的文件查询逻辑
        return FileType(
            id=id,
            path=f"/uploads/{id}.jpg",
            name=f"file_{id}.jpg",
            size=1024000,
            type="image/jpeg",
            created_at="2023-01-01T00:00:00Z",
            updated_at="2023-01-01T00:00:00Z"
        )
    
    def resolve_files(self, info, filter=None, limit=20, offset=0):
        """解析文件列表"""
        # 这里应该实现实际的文件列表查询逻辑
        files = []
        for i in range(limit):
            files.append(FileType(
                id=f"file_{offset + i}",
                path=f"/uploads/file_{offset + i}.jpg",
                name=f"file_{offset + i}.jpg",
                size=1024000 + i * 1000,
                type="image/jpeg",
                created_at="2023-01-01T00:00:00Z",
                updated_at="2023-01-01T00:00:00Z"
            ))
        return files
    
    def resolve_metadata(self, info, file_id):
        """解析文件元数据"""
        # 这里应该实现实际的元数据查询逻辑
        from .exiftool_processor import ExifToolProcessor
        processor = ExifToolProcessor()
        
        # 假设根据file_id获取文件路径
        file_path = f"/uploads/{file_id}.jpg"
        
        try:
            raw_metadata = processor.extract_metadata(file_path)
            
            return MetadataType(
                file_name=raw_metadata.get('FileName'),
                file_size=raw_metadata.get('FileSize'),
                file_type=raw_metadata.get('FileType'),
                creation_date=raw_metadata.get('CreateDate'),
                modification_date=raw_metadata.get('ModifyDate'),
                camera_make=raw_metadata.get('Make'),
                camera_model=raw_metadata.get('Model'),
                gps_latitude=raw_metadata.get('GPSLatitude'),
                gps_longitude=raw_metadata.get('GPSLongitude'),
                raw_data=json.dumps(raw_metadata)
            )
        except Exception as e:
            return None
    
    def resolve_search_metadata(self, info, query, filters=None):
        """搜索元数据"""
        # 这里应该实现实际的元数据搜索逻辑
        from .search_service import MetadataSearchService
        search_service = MetadataSearchService()
        
        filter_dict = json.loads(filters) if filters else {}
        results, _ = search_service.search(query, filter_dict)
        
        metadata_list = []
        for result in results:
            metadata_list.append(MetadataType(
                file_name=result.get('file_name'),
                file_size=result.get('file_size'),
                file_type=result.get('file_type'),
                creation_date=result.get('creation_date'),
                modification_date=result.get('modification_date'),
                camera_make=result.get('camera_make'),
                camera_model=result.get('camera_model'),
                gps_latitude=result.get('gps_latitude'),
                gps_longitude=result.get('gps_longitude'),
                raw_data=json.dumps(result.get('raw_data', {}))
            ))
        
        return metadata_list
    
    def resolve_task(self, info, id):
        """解析单个任务"""
        # 这里应该实现实际的任务查询逻辑
        return TaskType(
            id=id,
            status="completed",
            progress=1.0,
            result=json.dumps({"files_processed": 10}),
            error_message=None,
            created_at="2023-01-01T00:00:00Z",
            updated_at="2023-01-01T00:00:00Z"
        )
    
    def resolve_tasks(self, info, status=None, limit=20, offset=0):
        """解析任务列表"""
        # 这里应该实现实际的任务列表查询逻辑
        tasks = []
        for i in range(limit):
            tasks.append(TaskType(
                id=f"task_{offset + i}",
                status=status or "completed",
                progress=1.0,
                result=json.dumps({"files_processed": i + 1}),
                error_message=None,
                created_at="2023-01-01T00:00:00Z",
                updated_at="2023-01-01T00:00:00Z"
            ))
        return tasks
    
    def resolve_current_user(self, info):
        """解析当前用户"""
        # 这里应该从认证上下文获取用户信息
        return UserType(
            id="user_123",
            username="testuser",
            email="test@example.com",
            api_keys=["ak_example123", "ak_example456"],
            created_at="2023-01-01T00:00:00Z"
        )

class ExtractMetadata(Mutation):
    """提取元数据变更"""
    
    class Arguments:
        file = Upload(required=True)
        options = String()  # JSON字符串
    
    task = Field(TaskType)
    
    def mutate(self, info, file, options=None):
        """执行元数据提取"""
        import tempfile
        import os
        
        # 保存上传的文件
        with tempfile.NamedTemporaryFile(delete=False) as temp_file:
            file.save(temp_file.name)
            temp_path = temp_file.name
        
        try:
            # 解析选项
            option_dict = json.loads(options) if options else {}
            
            # 创建任务
            task_id = str(uuid.uuid4())
            
            # 提交异步处理
            from .tasks import extract_metadata_async
            extract_metadata_async.delay(
                task_id=task_id,
                file_path=temp_path,
                options=option_dict
            )
            
            return ExtractMetadata(task=TaskType(
                id=task_id,
                status="pending",
                progress=0.0,
                result=None,
                error_message=None,
                created_at=datetime.now().isoformat(),
                updated_at=datetime.now().isoformat()
            ))
            
        except Exception as e:
            # 清理临时文件
            if os.path.exists(temp_path):
                os.unlink(temp_path)
            raise e

class BatchExtractMetadata(Mutation):
    """批量提取元数据变更"""
    
    class Arguments:
        files = List(Upload, required=True)
        options = String()  # JSON字符串
    
    task = Field(TaskType)
    
    def mutate(self, info, files, options=None):
        """执行批量元数据提取"""
        import tempfile
        import os
        
        temp_paths = []
        
        try:
            # 保存所有上传的文件
            for file in files:
                with tempfile.NamedTemporaryFile(delete=False) as temp_file:
                    file.save(temp_file.name)
                    temp_paths.append(temp_file.name)
            
            # 解析选项
            option_dict = json.loads(options) if options else {}
            
            # 创建批量任务
            task_id = str(uuid.uuid4())
            
            # 提交异步批量处理
            from .tasks import batch_extract_metadata_async
            batch_extract_metadata_async.delay(
                task_id=task_id,
                file_paths=temp_paths,
                options=option_dict
            )
            
            return BatchExtractMetadata(task=TaskType(
                id=task_id,
                status="pending",
                progress=0.0,
                result=None,
                error_message=None,
                created_at=datetime.now().isoformat(),
                updated_at=datetime.now().isoformat()
            ))
            
        except Exception as e:
            # 清理临时文件
            for temp_path in temp_paths:
                if os.path.exists(temp_path):
                    os.unlink(temp_path)
            raise e

class UpdateMetadata(Mutation):
    """更新元数据变更"""
    
    class Arguments:
        file_id = String(required=True)
        metadata = String(required=True)  # JSON字符串
    
    success = String()
    
    def mutate(self, info, file_id, metadata):
        """更新文件元数据"""
        try:
            # 解析元数据
            metadata_dict = json.loads(metadata)
            
            # 这里应该实现实际的元数据更新逻辑
            from .exiftool_processor import ExifToolProcessor
            processor = ExifToolProcessor()
            
            # 假设根据file_id获取文件路径
            file_path = f"/uploads/{file_id}.jpg"
            
            # 更新元数据
            processor.update_metadata(file_path, metadata_dict)
            
            return UpdateMetadata(success="Metadata updated successfully")
            
        except Exception as e:
            raise Exception(f"Failed to update metadata: {str(e)}")

class Mutation(ObjectType):
    """GraphQL变更"""
    extract_metadata = ExtractMetadata.Field()
    batch_extract_metadata = BatchExtractMetadata.Field()
    update_metadata = UpdateMetadata.Field()

# 创建GraphQL Schema
schema = Schema(query=Query, mutation=Mutation)

class GraphQLAPIService:
    """GraphQL API服务"""
    
    def __init__(self, app: Flask):
        self.app = app
        self.setup_graphql()
    
    def setup_graphql(self):
        """设置GraphQL端点"""
        self.app.add_url_rule(
            '/graphql',
            view_func=GraphQLView.as_view(
                'graphql',
                schema=schema,
                graphiql=True  # 启用GraphiQL界面
            )
        )
        
        # 添加CORS支持
        @self.app.after_request
        def after_request(response):
            response.headers.add('Access-Control-Allow-Origin', '*')
            response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
            response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
            return response

API文档生成

from flask import Flask
from flask_restx import Api, Resource, fields, Namespace
from flask_restx.marshalling import marshal
from werkzeug.datastructures import FileStorage
import os

class DocumentedAPIService:
    """带文档的API服务"""
    
    def __init__(self):
        self.app = Flask(__name__)
        
        # 配置API文档
        self.api = Api(
            self.app,
            version='2.0',
            title='ExifTool API',
            description='A comprehensive API for metadata extraction and manipulation',
            doc='/docs/',  # 文档路径
            contact='support@example.com',
            license='MIT',
            license_url='https://opensource.org/licenses/MIT'
        )
        
        # 创建命名空间
        self.metadata_ns = Namespace('metadata', description='Metadata operations')
        self.files_ns = Namespace('files', description='File operations')
        self.auth_ns = Namespace('auth', description='Authentication')
        
        # 注册命名空间
        self.api.add_namespace(self.metadata_ns, path='/api/v2/metadata')
        self.api.add_namespace(self.files_ns, path='/api/v2/files')
        self.api.add_namespace(self.auth_ns, path='/api/v2/auth')
        
        # 定义模型
        self._define_models()
        
        # 注册资源
        self._register_resources()
    
    def _define_models(self):
        """定义API模型"""
        
        # 基础响应模型
        self.base_response = self.api.model('BaseResponse', {
            'status': fields.String(required=True, description='Response status'),
            'message': fields.String(required=True, description='Response message'),
            'timestamp': fields.Float(required=True, description='Response timestamp'),
            'request_id': fields.String(required=True, description='Request ID')
        })
        
        # 错误响应模型
        self.error_response = self.api.inherit('ErrorResponse', self.base_response, {
            'errors': fields.List(fields.String, description='Error messages')
        })
        
        # 元数据模型
        self.metadata_model = self.api.model('Metadata', {
            'FileName': fields.String(description='File name'),
            'FileSize': fields.Integer(description='File size in bytes'),
            'FileType': fields.String(description='File type'),
            'MIMEType': fields.String(description='MIME type'),
            'CreateDate': fields.String(description='Creation date'),
            'ModifyDate': fields.String(description='Modification date'),
            'Make': fields.String(description='Camera manufacturer'),
            'Model': fields.String(description='Camera model'),
            'GPSLatitude': fields.Float(description='GPS latitude'),
            'GPSLongitude': fields.Float(description='GPS longitude')
        })
        
        # 元数据提取请求模型
        self.extract_request = self.api.model('ExtractRequest', {
            'file_path': fields.String(required=True, description='Path to the file'),
            'tags': fields.List(fields.String, description='Specific tags to extract'),
            'output_format': fields.String(description='Output format', enum=['json', 'xml', 'csv']),
            'options': fields.Raw(description='Additional options')
        })
        
        # 元数据提取响应模型
        self.extract_response = self.api.inherit('ExtractResponse', self.base_response, {
            'data': fields.Nested(self.api.model('ExtractData', {
                'metadata': fields.Nested(self.metadata_model),
                'file_info': fields.Raw(description='File information'),
                'extraction_time': fields.Float(description='Extraction time in seconds')
            }))
        })
        
        # 批量处理请求模型
        self.batch_request = self.api.model('BatchRequest', {
            'files': fields.List(fields.String, required=True, description='List of file paths'),
            'options': fields.Raw(description='Processing options'),
            'async_processing': fields.Boolean(description='Enable async processing'),
            'callback_url': fields.String(description='Callback URL for async processing')
        })
        
        # 任务模型
        self.task_model = self.api.model('Task', {
            'task_id': fields.String(required=True, description='Task ID'),
            'status': fields.String(required=True, description='Task status', 
                                  enum=['pending', 'processing', 'completed', 'failed', 'cancelled']),
            'progress': fields.Float(description='Task progress (0.0 - 1.0)'),
            'created_at': fields.Float(description='Creation timestamp'),
            'updated_at': fields.Float(description='Last update timestamp'),
            'result': fields.Raw(description='Task result'),
            'error_message': fields.String(description='Error message if failed')
        })
        
        # 分页模型
        self.pagination_model = self.api.model('Pagination', {
            'page': fields.Integer(description='Current page'),
            'per_page': fields.Integer(description='Items per page'),
            'total': fields.Integer(description='Total items'),
            'pages': fields.Integer(description='Total pages'),
            'has_next': fields.Boolean(description='Has next page'),
            'has_prev': fields.Boolean(description='Has previous page')
        })
        
        # 搜索响应模型
        self.search_response = self.api.inherit('SearchResponse', self.base_response, {
            'data': fields.Nested(self.api.model('SearchData', {
                'results': fields.List(fields.Nested(self.metadata_model)),
                'pagination': fields.Nested(self.pagination_model)
            }))
        })
        
        # 认证模型
        self.login_request = self.api.model('LoginRequest', {
            'username': fields.String(required=True, description='Username'),
            'password': fields.String(required=True, description='Password'),
            'remember_me': fields.Boolean(description='Remember login')
        })
        
        self.login_response = self.api.inherit('LoginResponse', self.base_response, {
            'data': fields.Nested(self.api.model('LoginData', {
                'access_token': fields.String(description='JWT access token'),
                'token_type': fields.String(description='Token type'),
                'expires_in': fields.Integer(description='Token expiration time'),
                'user': fields.Raw(description='User information')
            }))
        })
        
        # API密钥模型
        self.api_key_request = self.api.model('APIKeyRequest', {
            'name': fields.String(required=True, description='API key name'),
            'rate_limit': fields.Integer(description='Rate limit per hour')
        })
        
        self.api_key_response = self.api.inherit('APIKeyResponse', self.base_response, {
            'data': fields.Nested(self.api.model('APIKeyData', {
                'api_key': fields.String(description='Generated API key'),
                'name': fields.String(description='API key name'),
                'rate_limit': fields.Integer(description='Rate limit'),
                'created_at': fields.Float(description='Creation timestamp')
            }))
        })
    
    def _register_resources(self):
        """注册API资源"""
        
        @self.metadata_ns.route('/extract')
        class MetadataExtract(Resource):
            @self.metadata_ns.doc('extract_metadata')
            @self.metadata_ns.expect(self.extract_request)
            @self.metadata_ns.marshal_with(self.extract_response, code=200)
            @self.metadata_ns.marshal_with(self.error_response, code=400)
            @self.metadata_ns.response(401, 'Unauthorized')
            @self.metadata_ns.response(429, 'Rate limit exceeded')
            def post(self):
                """Extract metadata from a file
                
                This endpoint extracts metadata from the specified file using ExifTool.
                Supports various file formats including images, videos, and documents.
                
                Returns:
                    Extracted metadata in the specified format
                """
                # 实际实现逻辑
                pass
        
        @self.metadata_ns.route('/batch')
        class MetadataBatch(Resource):
            @self.metadata_ns.doc('batch_extract_metadata')
            @self.metadata_ns.expect(self.batch_request)
            @self.metadata_ns.marshal_with(self.extract_response, code=200)
            @self.metadata_ns.marshal_with(self.error_response, code=400)
            def post(self):
                """Batch extract metadata from multiple files
                
                Process multiple files in a single request. Supports both
                synchronous and asynchronous processing modes.
                
                For async processing, returns a task ID that can be used
                to check the processing status.
                """
                pass
        
        @self.metadata_ns.route('/search')
        class MetadataSearch(Resource):
            @self.metadata_ns.doc('search_metadata')
            @self.metadata_ns.param('query', 'Search query', required=True)
            @self.metadata_ns.param('page', 'Page number', type=int, default=1)
            @self.metadata_ns.param('per_page', 'Items per page', type=int, default=20)
            @self.metadata_ns.param('sort_by', 'Sort field', default='created_at')
            @self.metadata_ns.param('sort_order', 'Sort order', enum=['asc', 'desc'], default='desc')
            @self.metadata_ns.marshal_with(self.search_response, code=200)
            def get(self):
                """Search metadata records
                
                Search through previously extracted metadata using various
                criteria including file properties, EXIF data, and custom tags.
                
                Supports pagination and sorting options.
                """
                pass
        
        @self.files_ns.route('/upload')
        class FileUpload(Resource):
            upload_parser = self.api.parser()
            upload_parser.add_argument('file', location='files', type=FileStorage, required=True)
            upload_parser.add_argument('extract_metadata', type=bool, default=True)
            
            @self.files_ns.doc('upload_file')
            @self.files_ns.expect(upload_parser)
            @self.files_ns.marshal_with(self.extract_response, code=201)
            def post(self):
                """Upload and process a file
                
                Upload a file to the server and optionally extract its metadata.
                The file is stored securely and can be referenced by the returned ID.
                """
                pass
        
        @self.files_ns.route('/tasks/<string:task_id>')
        class TaskStatus(Resource):
            @self.files_ns.doc('get_task_status')
            @self.files_ns.marshal_with(self.task_model, code=200)
            @self.files_ns.response(404, 'Task not found')
            def get(self, task_id):
                """Get task status
                
                Retrieve the current status of an asynchronous processing task.
                Includes progress information and results when completed.
                """
                pass
            
            @self.files_ns.doc('cancel_task')
            @self.files_ns.marshal_with(self.task_model, code=200)
            def delete(self, task_id):
                """Cancel a task
                
                Cancel a pending or running task. Completed tasks cannot be cancelled.
                """
                pass
        
        @self.auth_ns.route('/login')
        class AuthLogin(Resource):
            @self.auth_ns.doc('login')
            @self.auth_ns.expect(self.login_request)
            @self.auth_ns.marshal_with(self.login_response, code=200)
            @self.auth_ns.response(401, 'Invalid credentials')
            def post(self):
                """User login
                
                Authenticate a user and return a JWT access token.
                The token should be included in the Authorization header
                for subsequent requests.
                """
                pass
        
        @self.auth_ns.route('/api-keys')
        class APIKeys(Resource):
            @self.auth_ns.doc('create_api_key')
            @self.auth_ns.expect(self.api_key_request)
            @self.auth_ns.marshal_with(self.api_key_response, code=201)
            @self.auth_ns.header('Authorization', 'JWT token', required=True)
            def post(self):
                """Create API key
                
                Generate a new API key for programmatic access.
                Requires valid JWT authentication.
                """
                pass
            
            @self.auth_ns.doc('list_api_keys')
            @self.auth_ns.marshal_with(self.api_key_response, code=200)
            @self.auth_ns.header('Authorization', 'JWT token', required=True)
            def get(self):
                """List API keys
                
                Retrieve all API keys for the authenticated user.
                """
                pass
    
    def run(self, host='0.0.0.0', port=5000, debug=False):
        """运行API服务"""
        self.app.run(host=host, port=port, debug=debug)

# 使用示例
if __name__ == '__main__':
    service = DocumentedAPIService()
    service.run(debug=True)

总结

本章详细介绍了ExifTool的API服务开发,包括:

核心知识点

  • RESTful API设计原则和实现
  • GraphQL API架构和查询语言
  • API版本控制和向后兼容
  • 认证授权机制(JWT、API Key)
  • 请求限流和安全防护
  • API文档自动生成

实用技能

  • Flask-RESTful框架使用
  • GraphQL Schema设计
  • API密钥管理系统
  • 异步任务处理
  • 错误处理和响应标准化
  • API监控和日志记录

最佳实践

  • 统一的响应格式
  • 完善的错误处理
  • 合理的缓存策略
  • 安全的认证机制
  • 详细的API文档
  • 性能监控和优化

扩展思考

高级特性开发

  • API网关集成:Kong、Zuul等
  • 服务发现:Consul、Eureka
  • 负载均衡:Nginx、HAProxy
  • API测试:自动化测试框架

企业级功能

  • 多租户支持:数据隔离和权限管理
  • API分析:使用统计和性能分析
  • SLA监控:服务质量保证
  • 灾难恢复:备份和恢复策略

集成和扩展

  • 第三方集成:云服务、支付系统
  • Webhook支持:事件通知机制
  • SDK开发:多语言客户端库
  • API市场:开放平台建设

下一步学习

相关章节

进阶主题

  • 微服务架构设计
  • 容器化部署
  • 服务网格技术
  • API安全最佳实践

实战项目

  • 构建完整的API服务
  • 实现API网关
  • 开发客户端SDK
  • 建设开发者平台

参考资源

官方文档

开发工具

学习资源

社区资源

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

lvjesus

码力充电

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值