Package: emmett-rest
Emmett-RESTEmmett-REST is a REST extension forEmmett framework.In a nutshellfromemmett.ormimportModel,Fieldfromemmett_restimportRESTclassTask(Model):title=Field.string()is_completed=Field.bool()app.use_extension(REST)tasks=app.rest_module(__name__,'api_task',Task,url_prefix='tasks')tasks.query_allowed_fields=['is_completed']Output ofhttp://{host}/tasks?where={"is_completed": false}:{"meta":{"object":"list","total_objects":1,"has_more":false},"data":[{"title":"Some task","is_completed":false}]}InstallationYou can install Emmett-REST using pip:pip install Emmett-RESTAnd add it to your Emmett application:fromemmett_restimportRESTrest=app.use_extension(REST)UsageThe Emmett-REST extension is intended to be used with Emmettmodels, and it uses application modules to build APIs over them.Let's say, for example, that you have a task manager app with aTaskmodel:fromemmett.ormimportModel,FieldclassTask(Model):title=Field.string()is_completed=Field.bool()spent_time=Field.int()created_at=Field.datetime()Then, in order to expose REST apis for yourTaskmodel, you can use therest_modulemethod on your application or on any application module:frommyappimportapp,Tasktasks=app.rest_module(__name__,'api_task',Task,url_prefix='tasks')As you can see, the usage is very similar to the Emmett application modules, but we also passed the involved model to the module initialization.This single line is enough to have a really simple REST api over theTaskmodel, since under the default behaviour rest modules will expose 5 different routes:anindexroute that will respond toGETrequests on/taskspath listing all the tasks in the databaseareadroute that will respond toGETrequests on/tasks/<int:rid>path returning a single task corresponding to the record id of theridvariableacreateroute that will respond toPOSTrequests on/tasksthat will create a new task in the databaseanupdateroute that will respond toPUTorPATCHrequests on/tasks/<int:rid>that will update the task corresponding to the record id of theridvariableadeleteroute that will respond toDELETErequests on/tasks/<int:rid>that will delete the task corresponding to the record id of theridvariable.Listing endpoints accepts some query parameters by default, and specifically:parameterdefaultdescriptionpage1page to returnpage_size20number of records per page to returnsort_byidfield to sort records by (-field for desc)where{}json dumped string for queryingAdditional routesEmmett-REST also provides some additional not CRUD endpoints. Let's see them specifically.Sample routeIt behaves like theindexroute, but gets records randomly. As a consequence, thesort_byparameter gets ignored.Responds toGETrequests on{base_path}/sampleendpoint, and can be activated adding'sample'to the enabled methods on module definition or in the extension settings.Grouping routeResponds toGETrequests on{base_path}/group/<str:field>endpoint, and can be activated adding'group'to the enabled methods on module definition or in the extension settings.It groups by value and count records for the given field. Results of callinghttp://{host}/tasks/group/is_completedlooks like:{"meta":{"object":"list","total_objects":11,"has_more":false},"data":[{"value":true,"count":1},{"value":false,"count":10}]}Stats routeResponds toGETrequests on{base_path}/statsendpoint, and can be activated adding'stats'to the enabled methods on module definition or in the extension settings. Parse a list of fields from the comma separatedfieldsquery parameter.It return minimum, maximum and average values of the records for the specified fields. Results of callinghttp://{host}/tasks/stats?fields=spent_timelooks like:{"spent_time":{"min":0,"max":3600,"avg":27}}REST module parametersTherest_modulemethod accepts several parameters (bold ones are required) for its configuration:parameterdefaultdescriptionimport_nameas for standard modulesnameas for standard modulesmodelthe model to useserializerNonea class to be used for serializationparserNonea class to be used for parsingenabled_methodsstrlist: index, create, read, update, deletethe routes that should be enabled on the moduledisabled_methods[]the routes that should be disabled on the moduledefault_sortNonethe field used for sorting records by default (use model primary key unless specified)use_saveTruewhether to use thesavemethod in records to perform operations or the low-level ORM APIsuse_destroyTruewhether to use thedestroymethod in records to perform operations or the low-level ORM APIslist_envelopedatathe envelope to use on the index routesingle_envelopeFalsethe envelope to use on all the routes except for lists endpointsmeta_envelopemetathe envelope to use for meta datagroups_envelopedatathe envelope to use for the grouping endpointuse_envelope_on_parseFalseif set toTruewill use the envelope specified insingle_envelopeoption also on parsingserialize_metaTruewhether to serialize meta data on lists endpointbase_path/the default path prefix for routes not involving a single recordid_path/<int:rid>the default path prefix for routes involving a single recordurl_prefixNoneas for standard moduleshostnameNoneas for standard modulesmodule_classRestModulethe module class to useREST module propertiesSome of the REST module parameters needs to be configured using attributes, specifically:attributedescriptionallowed_sortslist of fields that can be used withsort_byparameterquery_allowed_fieldslist of fields that can be used withwhereparametergrouping_allowed_fieldslist of fields that can be used ingrouproutestats_allowed_fieldslist of fields that can be used instatsrouteAn example would be:tasks.allowed_sorts=['id','created_at']tasks.query_allowed_fields=['is_completed']tasks.grouping_allowed_fields=['is_completed']tasks.stats_allowed_fields['time_spent']Customizing the database setUnder default behavior, any REST module will useModel.all()as the database set on every operation.When you need to customize it, you can use theget_dbsetdecorator.
For example, you may gonna use the Emmett auth module:frommyappimportauth@tasks.get_dbsetdeffetch_tasks():returnauth.user.tasksor you may have some soft-deletions strategies and want to expose just the records which are not deleted:@tasks.get_dbsetdeffetch_tasks():returnTask.where(lambdat:t.is_deleted==False)Customizing single row fetchingUnder default behaviour, any REST module will use standard select to fetch the record on thereadendpoint.When you need to customize it, you can use theget_rowdecorator.For example, you may want to add a left join to the selection:@tasks.get_rowdeffetch_row(dbset):returndbset.select(including=['many_relation'],limitby=(0,1)).first()Customizing routed methodsYou can customize every route of the REST module using itsindex,create,read,updateanddeletedecorators. In the next examples we'll override the routes with the default ones, in order to show the original code behind the default routes.fromemmettimportrequest@tasks.index()asyncdeftask_list(dbset):pagination=tasks.get_pagination()sort=tasks.get_sort()rows=dbset.select(paginate=pagination,orderby=sort)returntasks.serialize_many(rows,dbset,pagination)As you can see, anindexmethod should accept thedbsetparameter, that is injected by the module. This is the default one or the one you defined with theget_dbsetdecorator.@tasks.read()asyncdeftask_single(row):returntasks.serialize_one(row)Thereadmethod should accept therowparameter that is injected by the module. Under default behaviour the module won't call your method if it doesn't find the requested record, but instead will return a 404 HTTP response.@tasks.create()asyncdeftask_new():response.status=201attrs=awaittasks.parse_params()row=Task.new(**attrs)ifnotrow.save()::response.status=422returntasks.error_422(row.validation_errors)returntasks.serialize_one(row)Thecreatemethod won't need any parameters, and is responsible of creating new records in the database.Note:since Emmett 2.4, asavemethod is available on records. Emmett-REST acts accordingly to theuse_saveconfiguration parameter (in extension configuration or module initialization), usingModel.createwhen saving is disabled.Theupdateanddeletemethods are quite similar:@tasks.update()asyncdeftask_edit(dbset,rid):attrs=awaittasks.parse_params()row=dbset.where(Task.id==rid).select().first()ifnotrow:response.status=404returntasks.error_404()row.update(**attrs)ifnotrow.save():response.status=422returntasks.error_422(row.validation_errors)returntasks.serialize_one(row)@tasks.delete()asyncdeftask_del(dbset,rid):row=dbset.where(Task.id==rid).select().first()ifnotrowornotrow.destroy():response.status=404returnself.error_404()return{}since, as you can see, they should accept thedbsetparameter and theridone, which will be the record id requested by the client.Note:since Emmett 2.4,saveanddestroymethod are available on records. Emmett-REST acts accordingly to theuse_saveanduse_destroyconfiguration parameter (in extension configuration or module initialization), usingdbset.updateanddbset.deletewhen saving and/or destroying is disabled.All the decorators accept an additionalpipelineparameter that you can use to add custom pipes to the routed function:@tasks.index(pipeline=[MyCustomPipe()])deftask_index:# codeCustomizing errorsYou can define custom methods for the HTTP 400, 404 and 422 errors that will generate the JSON output using theon_400,on_404andon_422decorators:@tasks.on_400deftask_400err():return{'error':'this is my 400 error'}@tasks.on_404deftask_404err():return{'error':'this is my 404 error'}@tasks.on_422deftask_422err(errors):return{'error':422,'validation':errors}Customizing meta generationYou can define custom method for themetageneration using the appropriatemeta_builderdecorator:@tasks.meta_builderdef_tasks_meta(self,dbset,pagination):count=dbset.count()page,page_size=paginationtotal_pages=math.ceil(count/page_size)return{'page':page,'page_prev':page-1ifpage>1elseNone,'page_next':page+1ifpage<total_pageselseNone,'total_pages':total_pages,'total_objects':count}SerializationUnder the default behaviour, the REST extension will use thefields_rwattribute of the involved model, and overwrite the results with the contents of therest_rwattribute if present.For example, with this model:fromemmett.ormimportModel,FieldclassTask(Model):title=Field.string()is_completed=Field.bool()created_at=Field.datetime()fields_rw={'id':False,'created_at':False}the REST extension will serialize just thetitleand theis_completedfields, while with this:fromemmett.ormimportModel,FieldclassTask(Model):title=Field.string()is_completed=Field.bool()created_at=Field.datetime()fields_rw={'id':False,'created_at':False}rest_rw={'id':True}the REST extension will serialize also theidfield.SerializersWhenever you need more control over the serialization, you can use theSerializerclass of the REST extension:fromemmett_restimportSerializerclassTaskSerializer(Serializer):attributes=['id','title']tasks=app.rest_module(__name__,'api_task',Task,serializer=TaskSerializer,url_prefix='tasks')Serializers are handy when you want to add custom function to serialize something present in your rows. For instance, let's say you have a very simple tagging system:fromemmett.ormimportbelongs_to,has_manyclassTask(Model):has_many({'tags':'TaskTag'})classTaskTag(Model):belongs_to('task')name=Field.string()and you want to serialize the tags as an embedded list in your task. Then you just have to add atagsmethod to your serializer:classTaskSerializer(Serializer):attributes=['id','title']deftags(self,row):returnrow.tags().column('name')This is the complete list of rules that the extension will take over serializers:attributesis read as first stepthefields_rwandrest_rwattributes of the model are used to fillattributeslist when this is emptythe fields in theincludelist will be added toattributesthe fields in theexcludelist will be removed fromattributesevery method defined in the serializer not starting with_will be called over serialization and its return value will be added to the JSON object in a key named as the methodYou can also use different serialization for the list route and the other ones:fromemmett_restimportSerializer,serializeclassTaskSerializer(Serializer):attributes=['id','title']classTaskDetailSerializer(TaskSerializer):include=['is_completed']tasks=app.module(__name__,'api_task',Task,serializer=TaskDetailSerializer,url_prefix='tasks')@tasks.index()deftask_list(dbset):rows=dbset.select(paginate=tasks.get_pagination())returnserialize(rows,TaskSerializer)Note:under default behaviour theserializemethod will use the serializer passed to the module.Parsing inputOpposite to the serialization, you will have input parsing to parse JSON requests and perform operations on the records.Under the default behaviour, the REST extension will use thefields_rwattribute of the involved model, and overwrite the results with the contents of therest_rwattribute if present.For example, with this model:fromemmett.ormimportModel,FieldclassTask(Model):title=Field.string()is_completed=Field.bool()created_at=Field.datetime()fields_rw={'id':False,'created_at':False}the REST extension will parse the input to allow just thetitleand theis_completedfields, while with this:fromemmett.ormimportModel,FieldclassTask(Model):title=Field.string()is_completed=Field.bool()created_at=Field.datetime()fields_rw={'id':False,'created_at':False}rest_rw={'id':(True,False)'created_at':True}the REST extension will allow also thecreated_atfield.ParsersVery similarly to theSerializerclass, the extension provides also aParserone:fromemmett_restimportParserclassTaskParser(Parser):attributes=['title']tasks=app.rest_module(__name__,app,'api_task',Task,parser=TaskParser,url_prefix='tasks')As for serializers, you can defineattributes,includeandexcludelists in a parser, and add custom methods that will parse the params:classTaskParser(Parser):attributes=['title']defcreated_at(self,params):# some codeand you also have theenvelopeattribute at your disposal in case you expect to have enveloped bodies overPOST,PUTandPATCHrequests:classTaskParser(Parser):envelope="task"TheParserclass also offers some decorators you might need in your code:parse_valueandprocessor.While the first one might be handy in conditions where you need to edit a single attribute:classTaskParser(Parser):_completed_map={"yes":True,"no":False}@Parser.parse_value("is_completed")defparse_completed(self,value):returnself._completed_map[value]the latter gives you access to all the parameters and the parsed dictionary, so you can deeply customise the parsing flow:classTaskParser(Parser):@Parser.processor()defcustom_parsing(self,params,obj):obj.status="completed"ifparams.is_completedelse"running"PaginationREST modules perform pagination over the listing routes under the default behaviour. This is performed with thepaginateoption during the select and the call to theget_paginationmethod.You can customize the name of the query params or the default page sizes with the extension configuration, or you can override the method completely with subclassing.CallbacksUnless overridden, the defaultcreate,updateanddeletemethods invoke callbacks you can attach to the module using the approriate decorator. Here is the complete list:callbackargumentsdescriptionbefore_create[sdict]called before the record insertionbefore_update[id|Row, sdict]called before the record gets update (when saving is disabled, the first argument is the id of the record, otherwise the record to be updated)after_parse_params[sdict]called after params are loaded from the request bodyafter_create[Row]called after the record insertionafter_update[Row]called after the record gets updatedafter_delete[id|Row]called after the record gets deleted (when destroying is disabled, the argument is the id of the record, otherwise the destroyed record)For example, you might need to notify some changes:@tasks.after_createdef_notify_task_creation(row):my_publishing_system.notify(f"Task{row['title']}was added")Query languageThewherequery parameter allow, within the fields specified inquery_allowed_fields, to query records in the listing routes using a JSON object.The query language is inspired to the MongoDB query language, and provides the following operators:operatorargument typedescription$andList[Dict[str, Any]]logical AND$orList[Dict[str, Any]]logical OR$notDict[str, Any]logical NOT$eqAnymatches specified value$neAnyinverse of $eq$inList[Any]matches any of the values in list$ninList[Any]inverse of $in$ltUnion[int, float, str]matches values less than specified value$gtUnion[int, float, str]matches values greater than specified value$leUnion[int, float, str]matches values less than or equal to specified value$geUnion[int, float, str]matches values greater than or equal to specified value$existsboolmatches not null or null values$likestrmatches specified like expression$ilikestrcase insensitive $like$containsstrmatches values containing specified value$icontainsstrcase insensitive $contains$geo.containsGeoDictGISST_Contains$geo.equalsGeoDictGISST_Equals$geo.intersectsGeoDictGISST_Intersects$geo.overlapsGeoDictGISST_Overlaps$geo.touchesGeoDictGISST_Touches$geo.withinGeoDictGISST_Within$geo.dwithinGeoDistanceDictGISST_DWithinwhereGeoDictindicates a dictionary with atypekey indicating the geometry type andcoordinatesarray containing the geometry points (eg:{"type": "point", "coordinates": [1, 2]}), whileGeoDistanceDictindicates a dictionary with ageometrykey containing aGeoDictand thedistanceone (eg:{"geometry": {"type": "point", "coordinates": [1, 2]}, "distance": 5}).The JSON condition always have fields' names as keys (except for$and,$or,$not) and operators as values, where$eqis the default one:{"is_completed":false,"priority":{"$gte":5}}OpenAPI schemasEmmett-REST provides utilities to automatically generate OpenAPI schemas and relevant UI.In order to produce JSON and YAML schemas, you can instantiate adocsmodule:docs=rest.docs_module(__name__,"api_docs",title="My APIs",version="1",modules_tree_prefix="api.v1")As you can see, thedocs_modulemethods requires amodules_tree_prefixparameter which instructs REST extensions which modules should be included in the schema.Note:ensure to define your REST modules before instantiating the OpenAPI one, as the latter will need modules to be pre-defined in order to make the inspection.Thedocs_modulemethod accepts several parameters (bold ones are required) for its configuration:parameterdefaultdescriptionimport_nameas for standard modulesnameas for standard modulestitlethe title for the OpenAPI schemaversionversion for the OpenAPI schemamodules_tree_prefixa prefix for modules names to be included in the schemadescriptionNonegeneral description for the schematagsNonetags for the schemaserversNoneservers for the schematerms_of_serviceNoneterms of service for the schemacontactNonecontact information for the schemalicense_infoNonelicense information for the schemasecurity_schemesNonesecurity information for the schemaproduce_schemasFalsewheter to generate OpenAPIschemaresources from modules serializers in addition to endpointsexpose_uiNonewheter to expose UI (under default behaviour will match the application debug flag)ui_path/docspath for the UI componenturl_prefixNoneas for standard moduleshostnameNoneas for standard modulesUnder default behaviour, Emmett-REST will generate OpenAPI schema considering your modules endpoints, inferring types from your models, serializers and parsers.Customising endpoints groupingUnder default behaviour, endpoints in generated OpenAPI schema are grouped by module. In case you need to change this, you can use the docs moduleregroupmethod:docs.regroup("api.v1.some_module","api.v1.another_module")OpenAPI modules' methodsEmmett-REST provides anopenapiobject in your REST modules to enable schema customisations. Specifically, this allows you to customise naming, serializers and parsers specs for your module methods:RESTModule.openapi.describe.entity(name)lets you specify a custom name for the module entityRESTModule.openapi.define.serializer(Serializer, methods)lets you specify different serialization specs for the given methodsRESTModule.openapi.define.parser(Parser, methods)lets you specify different deserialization specs for the given methodsOpenAPI decoratorsEmmett-REST provides anopenapidecorator to allow definition of additional routes and customisation of OpenAPI schema. Let's see them in detail.includeUsed to include a custom route in the resulting OpenAPI schema:fromemmett_rest.openapiimportopenapi@mymodule.route()@openapi.includeasyncdefcustom_method():...defineUsed to specify schemes and attributes in different contexts. Thedefinegroup provides several decorators with specific use-cases:openapi.define.fieldslet you specify the type and default values on serializers and parsers for conditions where such data cannot be directly inferredopenapi.define.requestlet you specify request specs on custom endpointsopenapi.define.responselet you specify response specs on custom endpointsopenapi.define.response_default_errorslet you re-use standard response errors on custom endpointsopenapi.define.serializerlet you re-use aSerializerspec on custom endpointsopenapi.define.parserlet you re-use aParserspec on custom endpointsdescribeUsed to describe OpenAPI schemes in different contexts. Thedescribegroup provides several decorators with specific use-cases:openapi.describelet you specify a summary and description on custom endpointsopenapi.describe.summarylet you specify a summary for custom endpointsopenapi.describe.descriptionlet you specify a description for custom endpointsopenapi.describe.requestlet you specify descriptions for request schemesopenapi.describe.responselet you specify descriptions for response schemesCustomizing REST modulesExtension optionsThis is the list of all the configuration variables available on the extension for customization – the default values are set:app.config.REST.default_module_class=RESTModuleapp.config.REST.default_serializer=Serializerapp.config.REST.default_parser=Parserapp.config.REST.page_param='page'app.config.REST.pagesize_param='page_size'app.config.REST.sort_param='sort_by'app.config.REST.query_param='where'app.config.REST.min_pagesize=10app.config.REST.max_pagesize=25app.config.REST.default_pagesize=20app.config.REST.default_sort=Noneapp.config.REST.base_path='/'app.config.REST.id_path='/<int:rid>'app.config.REST.list_envelope='data'app.config.REST.single_envelope=Falseapp.config.REST.groups_envelope='data'app.config.REST.use_envelope_on_parse=Falseapp.config.REST.serialize_meta=Trueapp.config.REST.meta_envelope='meta'app.config.REST.default_enabled_methods=['index','create','read','update','delete']app.config.REST.default_disabled_methods=[]app.config.REST.use_save=Trueapp.config.REST.use_destroy=TrueThis configuration will be used by all the REST modules you create, unless overridden.SubclassingUnder the default behavior, every REST module will use theRESTModuleclass. You can create as many subclasses from this one when you need to apply the same behaviour to several modules:fromemmett_restimportRESTModuleclassMyRESTModule(RESTModule):definit(self):self.disabled_methods=['delete']self.index_pipeline.append(MyCustomPipe())self.list_envelope='objects'self.single_envelope=self.model.__name__.lower()def_get_dbset(self):returnself.model.where(lambdam:m.user==session.user.id)asyncdef_index(self,dbset):rows=dbset.select(paginate=self.get_pagination())rv=self.serialize_many(rows)rv['meta']={'total':dbset.count()}returnrvtasks=app.rest_module(__name__,app,'api_task',Task,url_prefix='tasks',module_class=MyRESTModule)tags=app.rest_module(__name__,app,'api_tag',Tag,url_prefix='tags',module_class=MyRESTModule)As you can see, we defined a subclass of theRESTModuleone and used theinitmethod to customize the class initialization for our needs. Westronglyrecommend to use this method and avoid overriding the__init__of the class unless you really know what you're doing.Using theinitmethod, we disabled thedeleteroute over the module, added a custom pipe over theindexroute and configured the envelope rules.Here is a list of variables you may want to change inside theinitmethod:modelserializerparserenabled_methodsdisabled_methodslist_envelopesingle_envelopemeta_envelopegroups_envelopeuse_envelope_on_parsingAlso, this is the complete list of the pipeline variables and their default values:definit(self):self.index_pipeline=[SetFetcher(self),self._json_query_pipe]self.create_pipeline=[]self.read_pipeline=[SetFetcher(self),RecordFetcher(self)]self.update_pipeline=[SetFetcher(self)]self.delete_pipeline=[SetFetcher(self)]self.group_pipeline=[self._group_field_pipe,SetFetcher(self),self._json_query_pipe]self.stats_pipeline=[self._stats_field_pipe,SetFetcher(self),self._json_query_pipe]self.sample_pipeline=[SetFetcher(self),self._json_query_pipe]We've also overridden the methods for the database set retrieval and theindexroute. As you can see, these methods are starting with the_since are the default ones and you can still override them with decorators. This is the complete list of methods you may want to override instead of using decorators:_get_dbset_get_row_index_create_read_update_delete_group_stats_sample_build_metabuild_error_400build_error_404build_error_422There are some other methods you may need to override, like theget_paginationone or the serialization ones. Please, check the source code of theRESTModuleclass for further needs.LicenseEmmett-REST is released under BSD license. Check the LICENSE file for more details.