├── .gitignore ├── internal └── testing │ └── unit_tests │ ├── coverage.list │ └── UnitTest │ └── AppS │ ├── REST │ ├── Sample │ │ ├── Handler.cls │ │ ├── UserContext.cls │ │ ├── Data │ │ │ ├── Address.cls │ │ │ ├── Company.cls │ │ │ ├── Employee.cls │ │ │ ├── Utils.cls │ │ │ ├── Person.cls │ │ │ └── Vendor.cls │ │ └── Model │ │ │ └── Person.cls │ ├── SamplePersistentAdapted.cls │ ├── SampleUnprotectedClass.cls │ ├── QueryBuilder.cls │ └── DriveSample.cls │ └── Util │ └── Buffer.cls ├── cls └── AppS │ ├── REST │ ├── Exception │ │ ├── ParameterParsingException.cls │ │ ├── InvalidColumnException.cls │ │ └── QueryGenerationException.cls │ ├── Model │ │ ├── Action │ │ │ ├── SASchema.cls │ │ │ ├── t │ │ │ │ ├── actions.cls │ │ │ │ ├── argument.cls │ │ │ │ └── action.cls │ │ │ ├── Handler.cls │ │ │ ├── Projection.cls │ │ │ └── Generator.cls │ │ ├── ISerializable.cls │ │ ├── Proxy.cls │ │ ├── Adaptor.cls │ │ ├── Resource.cls │ │ ├── QueryResult.cls │ │ ├── ResourceMapProjection.cls │ │ └── DBMappedResource.cls │ ├── Authentication.cls │ ├── Authentication │ │ ├── PlatformUser.cls │ │ └── PlatformBased.cls │ ├── ResourceMap.cls │ ├── ActionMap.cls │ ├── Auditor.cls │ ├── QueryGenerator.cls │ └── Handler.cls │ └── Util │ ├── SASchemaClass.cls │ └── Buffer.cls ├── .github └── workflows │ ├── objectscript-quality.yml │ └── main.yml ├── samples └── phonebook │ ├── cls │ └── Sample │ │ └── Phonebook │ │ ├── REST │ │ ├── Handler.cls │ │ └── Model │ │ │ └── PhoneNumber.cls │ │ ├── Installer.cls │ │ └── Model │ │ ├── PhoneNumber.cls │ │ └── Person.cls │ └── module.xml ├── module.xml ├── LICENSE ├── CONTRIBUTING.md ├── README.md └── docs ├── user-guide.md └── sample-phonebook.md /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .vscode/settings.json 3 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/coverage.list: -------------------------------------------------------------------------------- 1 | AppS.REST.PKG 2 | AppS.Util.Buffer.CLS 3 | -------------------------------------------------------------------------------- /cls/AppS/REST/Exception/ParameterParsingException.cls: -------------------------------------------------------------------------------- 1 | Class AppS.REST.Exception.ParameterParsingException Extends QueryGenerationException [ System = 3 ] 2 | { 3 | 4 | Parameter ExceptionName = ""; 5 | 6 | Method DisplayMessage() As %String 7 | { 8 | Return "The parameter value '" _ ..Content _ "' could not be parsed." 9 | } 10 | 11 | } 12 | -------------------------------------------------------------------------------- /cls/AppS/REST/Exception/InvalidColumnException.cls: -------------------------------------------------------------------------------- 1 | Class AppS.REST.Exception.InvalidColumnException Extends QueryGenerationException [ System = 3 ] 2 | { 3 | 4 | Parameter ExceptionName = ""; 5 | 6 | Parameter HTTPErrorCode = "403 Unauthorized"; 7 | 8 | Method DisplayMessage() As %String 9 | { 10 | Return "Access to column '"_..Content_"' is not permitted." 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/objectscript-quality.yml: -------------------------------------------------------------------------------- 1 | name: objectscriptquality 2 | on: push 3 | 4 | jobs: 5 | linux: 6 | name: Linux build 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Execute ObjectScript Quality Analysis 11 | run: wget https://raw.githubusercontent.com/litesolutions/objectscriptquality-jenkins-integration/master/iris-community-hook.sh && sh ./iris-community-hook.sh 12 | 13 | -------------------------------------------------------------------------------- /cls/AppS/Util/SASchemaClass.cls: -------------------------------------------------------------------------------- 1 | Class AppS.Util.SASchemaClass Extends %Studio.SASchemaClass 2 | { 3 | 4 | /// Outputs the schema to the current device. 5 | /// Useful for testing/debugging. 6 | ClassMethod Display() 7 | { 8 | Try { 9 | Set tStream = ##class(%Stream.GlobalCharacter).%New() 10 | $$$ThrowOnError(..OutputToStream(tStream)) 11 | Do tStream.OutputToDevice() 12 | } Catch e { 13 | Write $System.Status.GetErrorText(e.AsStatus()) 14 | } 15 | } 16 | 17 | } 18 | 19 | -------------------------------------------------------------------------------- /samples/phonebook/cls/Sample/Phonebook/REST/Handler.cls: -------------------------------------------------------------------------------- 1 | Class Sample.Phonebook.REST.Handler Extends AppS.REST.Handler 2 | { 3 | 4 | ClassMethod AuthenticationStrategy() As %Dictionary.CacheClassname 5 | { 6 | Quit ##class(AppS.REST.Authentication.PlatformBased).%ClassName(1) 7 | } 8 | 9 | ClassMethod GetUserResource(pFullUserInfo As %DynamicObject) As AppS.REST.Authentication.PlatformUser 10 | { 11 | Quit ##class(AppS.REST.Authentication.PlatformUser).%New() 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/Sample/Handler.cls: -------------------------------------------------------------------------------- 1 | Class UnitTest.AppS.REST.Sample.Handler Extends AppS.REST.Handler 2 | { 3 | 4 | Parameter UseSession = 1; 5 | 6 | ClassMethod AuthenticationStrategy() As %Dictionary.CacheClassname 7 | { 8 | Quit "AppS.REST.Authentication.PlatformBased" 9 | } 10 | 11 | ClassMethod GetUserResource(pFullUserInfo As %DynamicObject) As UnitTest.AppS.REST.Sample.UserContext 12 | { 13 | Quit ##class(UnitTest.AppS.REST.Sample.UserContext).%New() 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /samples/phonebook/cls/Sample/Phonebook/REST/Model/PhoneNumber.cls: -------------------------------------------------------------------------------- 1 | Class Sample.Phonebook.REST.Model.PhoneNumber Extends AppS.REST.Model.Proxy [ DependsOn = Sample.Phonebook.Model.PhoneNumber ] 2 | { 3 | 4 | Parameter RESOURCENAME = "phone-number"; 5 | 6 | Parameter SOURCECLASS = "Sample.Phonebook.Model.PhoneNumber"; 7 | 8 | Parameter JSONMAPPING = "PhoneNumberWithPerson"; 9 | 10 | ClassMethod CheckPermission(pID As %String, pOperation As %String, pUserContext As AppS.REST.Authentication.PlatformUser) As %Boolean 11 | { 12 | Quit (pOperation = "CREATE") || (pOperation = "UPDATE") || (pOperation = "DELETE") 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/Action/SASchema.cls: -------------------------------------------------------------------------------- 1 | /// This class is internal to AppS.REST; consumers should not use or reference it directly. 2 | Class AppS.REST.Model.Action.SASchema Extends AppS.Util.SASchemaClass 3 | { 4 | 5 | /// This is the namespace value used to identify this SA schema. 6 | /// This corresponds to the XMLNamespace keyword of a Studio XData block. 7 | Parameter XMLNAMESPACE As STRING = "http://www.intersystems.com/apps/rest/action"; 8 | 9 | /// This is comma-separated list of the classes whose xml elements 10 | /// can be used as the root level element of a document. 11 | Parameter ROOTCLASSES As STRING = "AppS.REST.Model.Action.t.actions:actions"; 12 | 13 | } 14 | -------------------------------------------------------------------------------- /cls/AppS/REST/Authentication.cls: -------------------------------------------------------------------------------- 1 | /// Interface for authentication methods used in subclasses of AppS.REST.Handler 2 | Class AppS.REST.Authentication Extends %RegisteredObject [ Abstract ] 3 | { 4 | 5 | /// Implement to define custom authentication logic that will be run OnPreDispatch. 6 | /// If pContinue is set to false, the request will not be dispatched. 7 | ClassMethod Authenticate(pUrl As %String, ByRef pContinue As %Boolean) As %Status [ Abstract ] 8 | { 9 | } 10 | 11 | /// Returns authenticated user information for the request or session 12 | ClassMethod UserInfo(Output pUserInfo As %DynamicObject) As %Status [ Abstract ] 13 | { 14 | } 15 | 16 | ClassMethod Logout() As %Status [ Abstract ] 17 | { 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /module.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | AppS.REST 5 | 1.0.2 6 | Framework for rapid development of secure, sustainable REST APIs 7 | module 8 | 9 | 10 | 11 | 12 | 13 | Module 14 | 15 | 16 | -------------------------------------------------------------------------------- /cls/AppS/REST/Authentication/PlatformUser.cls: -------------------------------------------------------------------------------- 1 | Class AppS.REST.Authentication.PlatformUser Extends (%RegisteredObject, %JSON.Adaptor, AppS.REST.Model.ISerializable) 2 | { 3 | 4 | Property Username As %String [ InitialExpression = {$Username} ]; 5 | 6 | /// Serialize a JSON enabled class as a JSON document and write it to the current device. 7 | Method JSONExport() As %Status 8 | { 9 | Quit ..%JSONExport() 10 | } 11 | 12 | /// Serialize a JSON enabled class as a JSON document and write it to a stream. 13 | Method JSONExportToStream(ByRef export As %Stream.Object) As %Status 14 | { 15 | Quit ..%JSONExportToStream(.export) 16 | } 17 | 18 | /// Serialize a JSON enabled class as a JSON document and return it as a string. 19 | Method JSONExportToString(ByRef %export As %String) As %Status 20 | { 21 | Quit ..%JSONExportToString(.export) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /cls/AppS/REST/Exception/QueryGenerationException.cls: -------------------------------------------------------------------------------- 1 | Class AppS.REST.Exception.QueryGenerationException Extends %Exception.AbstractException [ Abstract, System = 3 ] 2 | { 3 | 4 | Parameter ExceptionName [ Abstract ]; 5 | 6 | Parameter HTTPErrorCode = "400 Bad Request"; 7 | 8 | Property Content As %String [ Private ]; 9 | 10 | Property ErrorStatus As %String; 11 | 12 | Method DisplayString() As %String 13 | { 14 | Quit "Invalid query. " _ ..DisplayMessage() 15 | } 16 | 17 | /// Appended to the end of DisplayString(); should be overwritten by subclasses to showcase information about the failure 18 | Method DisplayMessage() As %String 19 | { 20 | Quit "" 21 | } 22 | 23 | ClassMethod New(content As %String, errorStatus As %String = {..#HTTPErrorCode}) As QueryGenerationException 24 | { 25 | Set e = ..%New($Parameter($This,"ExceptionName")) 26 | Set e.Content = content 27 | Set e.ErrorStatus = errorStatus 28 | Quit e 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /samples/phonebook/cls/Sample/Phonebook/Installer.cls: -------------------------------------------------------------------------------- 1 | Class Sample.Phonebook.Installer Extends %ZPM.AbstractInstaller 2 | { 3 | 4 | ClassMethod OnConfigureComponent(pNamespace As %String, pVerbose As %Boolean = 0, ByRef pVars) As %Status 5 | { 6 | Set sc = $$$OK 7 | Try { 8 | If pVerbose { 9 | Write !,"[Sample.Phonebook.Installer:OnConfigureComponent] Populating sample data... " 10 | } 11 | $$$ThrowOnError(##class(Sample.Phonebook.Model.Person).%KillExtent()) 12 | $$$ThrowOnError(##class(Sample.Phonebook.Model.Person).Populate(100)) 13 | $$$ThrowOnError(##class(Sample.Phonebook.Model.PhoneNumber).Populate(300)) 14 | If pVerbose { 15 | Write "done." 16 | } 17 | } Catch e { 18 | Set sc = e.AsStatus() 19 | If pVerbose { 20 | Write "An error occurred: ",$System.Status.GetErrorText(sc) 21 | } 22 | } 23 | Quit sc 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/Sample/UserContext.cls: -------------------------------------------------------------------------------- 1 | Class UnitTest.AppS.REST.Sample.UserContext Extends (%RegisteredObject, %JSON.Adaptor, AppS.REST.Model.ISerializable) 2 | { 3 | 4 | Property Username As %String [ InitialExpression = {$Username} ]; 5 | 6 | Property IsAdmin As %Boolean [ InitialExpression = {(","_$Roles_",") [ ",%All,"} ]; 7 | 8 | /// Serialize a JSON enabled class as a JSON document and write it to the current device. 9 | Method JSONExport() As %Status 10 | { 11 | Quit ..%JSONExport() 12 | } 13 | 14 | /// Serialize a JSON enabled class as a JSON document and write it to a stream. 15 | Method JSONExportToStream(ByRef export As %Stream.Object) As %Status 16 | { 17 | Quit ..%JSONExportToStream(.export) 18 | } 19 | 20 | /// Serialize a JSON enabled class as a JSON document and return it as a string. 21 | Method JSONExportToString(ByRef %export As %String) As %Status 22 | { 23 | Quit ..%JSONExportToString(.export) 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /samples/phonebook/module.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sample.Phonebook 5 | 1.0.0 6 | module 7 | 8 | 9 | AppS.REST 10 | 1.x 11 | 12 | 13 | 14 | 23 | Module 24 | Sample.Phonebook.Installer 25 | 26 | 27 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/ISerializable.cls: -------------------------------------------------------------------------------- 1 | /// Interface for JSON-serializable/deserializable objects. 2 | Class AppS.REST.Model.ISerializable [ Abstract, System = 3 ] 3 | { 4 | 5 | Parameter MEDIATYPE = "application/json"; 6 | 7 | /// JSONImport imports JSON or dynamic object input into this object.
8 | /// The input argument is either JSON as a string or stream, or a subclass of %DynamicAbstractObject. 9 | Method JSONImport(input) As %Status [ Abstract ] 10 | { 11 | } 12 | 13 | /// Serialize a JSON enabled class as a JSON document and write it to the current device. 14 | Method JSONExport() As %Status [ Abstract ] 15 | { 16 | } 17 | 18 | /// Serialize a JSON enabled class as a JSON document and write it to a stream. 19 | Method JSONExportToStream(ByRef export As %Stream.Object) As %Status [ Abstract ] 20 | { 21 | } 22 | 23 | /// Serialize a JSON enabled class as a JSON document and return it as a string. 24 | Method JSONExportToString(ByRef %export As %String) As %Status [ Abstract ] 25 | { 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/Action/t/actions.cls: -------------------------------------------------------------------------------- 1 | /// This class is internal to AppS.REST; consumers should not use or reference it directly. 2 | Class AppS.REST.Model.Action.t.actions Extends (%RegisteredObject, %XML.Adaptor) [ System = 2 ] 3 | { 4 | 5 | Parameter NAMESPACE As STRING = "http://www.intersystems.com/apps/rest/action"; 6 | 7 | Parameter XMLIGNOREINVALIDATTRIBUTE As BOOLEAN = 0; 8 | 9 | Property actions As list Of AppS.REST.Model.Action.t.action(XMLNAME = "action", XMLPROJECTION = "element"); 10 | 11 | /// This callback method is invoked by the %ValidateObject method to 12 | /// provide notification that the current object is being validated. 13 | /// 14 | ///

If this method returns an error then %ValidateObject will fail. 15 | Method %OnValidateObject() As %Status [ Private, ServerOnly = 1 ] 16 | { 17 | Set sc = $$$OK 18 | For i=1:1:..actions.Count() { 19 | Set sc = $$$ADDSC(sc,..actions.GetAt(i).%ValidateObject()) 20 | } 21 | Quit sc 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /cls/AppS/REST/ResourceMap.cls: -------------------------------------------------------------------------------- 1 | /// This class is internal to AppS.REST; consumers should not use or reference it directly. 2 | Class AppS.REST.ResourceMap Extends %Persistent [ System = 3 ] 3 | { 4 | 5 | Index IDKEY On (ResourceName, MediaType) [ IdKey ]; 6 | 7 | Index ModelClass On ModelClass [ Unique ]; 8 | 9 | Property ResourceName As %String(MAXLEN = 128); 10 | 11 | Property MediaType As %String(MAXLEN = 128); 12 | 13 | Property ModelClass As %Dictionary.CacheClassname; 14 | 15 | Storage Default 16 | { 17 | 18 | 19 | %%CLASSNAME 20 | 21 | 22 | ModelClass 23 | 24 | 25 | ^AppS.REST.ResourceMapD 26 | ResourceMapDefaultData 27 | ^AppS.REST.ResourceMapD 28 | ^AppS.REST.ResourceMapI 29 | ^AppS.REST.ResourceMapS 30 | %Storage.Persistent 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/Action/t/argument.cls: -------------------------------------------------------------------------------- 1 | /// This class is internal to AppS.REST; consumers should not use or reference it directly. 2 | Class AppS.REST.Model.Action.t.argument Extends (%RegisteredObject, %XML.Adaptor) [ System = 2 ] 3 | { 4 | 5 | Parameter XMLIGNOREINVALIDATTRIBUTE As BOOLEAN = 0; 6 | 7 | Parameter NAMESPACE As STRING = "http://www.intersystems.com/apps/rest/action"; 8 | 9 | /// Name of the parameter (used in URLs) 10 | Property name As %String(XMLPROJECTION = "attribute"); 11 | 12 | /// Whether the the parameter value comes from the body, the URL, or (for instance actions) the ID of the object in question; default is 'url' 13 | Property source As %String(VALUELIST = ",body,url,id", XMLPROJECTION = "attribute") [ InitialExpression = "url", Required ]; 14 | 15 | /// Target parameter in the method/query 16 | Property target As %String(XMLPROJECTION = "attribute") [ Required ]; 17 | 18 | /// True if the argument is required (if not specified, it's a problem with the client) 19 | Property required As %Boolean(XMLPROJECTION = "attribute") [ InitialExpression = 0, Required ]; 20 | 21 | } 22 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/Action/Handler.cls: -------------------------------------------------------------------------------- 1 | /// This class is internal to AppS.REST; consumers should not use or reference it directly. 2 | Class AppS.REST.Model.Action.Handler [ Abstract, System = 3 ] 3 | { 4 | 5 | /// The class for which action handlers will be generated. 6 | Parameter SOURCECLASS As CLASSNAME; 7 | 8 | ClassMethod HandleInvokeClassAction(pHTTPMethod As %String, pAction As %String, pUserContext As AppS.REST.Model.Resource) [ CodeMode = objectgenerator ] 9 | { 10 | Set sc = $$$OK 11 | Try { 12 | Do ##class(AppS.REST.Model.Action.Generator).GenerateClassActions(%code, %compiledclass.Name) 13 | } Catch e { 14 | Set sc = e.AsStatus() 15 | } 16 | Quit sc 17 | } 18 | 19 | ClassMethod HandleInvokeInstanceAction(pHTTPMethod As %String, pInstance As AppS.REST.Model.Resource, pAction As %String, pUserContext As AppS.REST.Model.Resource) [ CodeMode = objectgenerator ] 20 | { 21 | Set sc = $$$OK 22 | Try { 23 | Do ##class(AppS.REST.Model.Action.Generator).GenerateInstanceActions(%code, %compiledclass.Name) 24 | } Catch e { 25 | Set sc = e.AsStatus() 26 | } 27 | Quit sc 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 InterSystems Corporation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Thank you for your interest in contributing! While this project originated at InterSystems, it is our hope that the community will continue to extend and enhance it. 4 | 5 | ## Submitting changes 6 | 7 | If you have made a change that you would like to contribute back to the community, please send a [GitHub Pull Request](/pull/new/master) explaining it. If your change fixes an issue that you or another user reported, please mention it in the pull request. You can find out more about pull requests [here](http://help.github.com/pull-requests/). 8 | 9 | ## Coding conventions 10 | 11 | Generally speaking, just try to match the conventions you see in the code you are reading. For this project, these include: 12 | 13 | * Do not use shortened command and function names - e.g., `s` instead of `set`, `$p` instead of `$piece` 14 | * One command per line 15 | * Do not use dot syntax 16 | * Indentation with tabs 17 | * Pascal case class and method names 18 | * Avoid using postconditionals 19 | * Local variables start with `t`; formal parameter names start with `p` 20 | * Always check %Status return values 21 | 22 | Thanks, 23 | Tim Leavitt, InterSystems Corporation 24 | -------------------------------------------------------------------------------- /cls/AppS/REST/Authentication/PlatformBased.cls: -------------------------------------------------------------------------------- 1 | /// A basic authentication option that uses platform-based (for example, username/password or delegated) authentication. 2 | /// Logout ends the CSP session (a no-op if sessions are not used). 3 | Class AppS.REST.Authentication.PlatformBased Extends AppS.REST.Authentication 4 | { 5 | 6 | /// Implement to define custom authentication logic that will be run OnPreDispatch. 7 | /// If pContinue is set to false, the request will not be dispatched. 8 | ClassMethod Authenticate(pUrl As %String, ByRef pContinue As %Boolean) As %Status 9 | { 10 | /// Uses password authentication, so nothing to do here. 11 | Set pContinue = 1 12 | Quit $$$OK 13 | } 14 | 15 | /// Returns no information at all; user info is determined entirely in the resource class instead. 16 | ClassMethod UserInfo(Output pUserInfo As %DynamicObject) As %Status 17 | { 18 | Set pUserInfo = {} 19 | Quit $$$OK 20 | } 21 | 22 | /// Implements logout in an appropriate way for the mode of authentication (e.g., revoking tokens or ending the session) 23 | ClassMethod Logout() As %Status 24 | { 25 | // For this example (with UseSession = 1), simply ends the session. 26 | Set %session.EndSession = 1 27 | Quit $$$OK 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/Sample/Data/Address.cls: -------------------------------------------------------------------------------- 1 | /// Use or operation of this code is subject to acceptance of the license available in the code repository for this code. 2 | /// This is a sample embeddable class representing an address. 3 | Class UnitTest.AppS.REST.Sample.Data.Address Extends (%SerialObject, %Populate, %XML.Adaptor, %JSON.Adaptor) 4 | { 5 | 6 | /// The street address. 7 | Property Street As %String(MAXLEN = 80, POPSPEC = "Street()"); 8 | 9 | /// The city name. 10 | Property City As %String(MAXLEN = 80, POPSPEC = "City()"); 11 | 12 | /// The 2-letter state abbreviation. 13 | Property State As %String(MAXLEN = 2, POPSPEC = "USState()"); 14 | 15 | /// The 5-digit U.S. Zone Improvement Plan (ZIP) code. 16 | Property Zip As %String(MAXLEN = 5, POPSPEC = "USZip()"); 17 | 18 | Storage Default 19 | { 20 | 21 | 22 | Street 23 | 24 | 25 | City 26 | 27 | 28 | State 29 | 30 | 31 | Zip 32 | 33 | 34 | AddressState 35 | ^UnitTest.AppS.REST3D03.AddressS 36 | %Storage.Serial 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/SamplePersistentAdapted.cls: -------------------------------------------------------------------------------- 1 | Class UnitTest.AppS.REST.SamplePersistentAdapted Extends (%Persistent, AppS.REST.Model.Adaptor) 2 | { 3 | 4 | /// Name of the resource at the REST level 5 | /// Subclasses MUST override this 6 | Parameter RESOURCENAME As STRING = "unittest-sample-persistent-adapted"; 7 | 8 | Parameter JSONMAPPING As STRING = "FooBarMapping"; 9 | 10 | XData FooBarMapping [ XMLNamespace = "http://www.intersystems.com/jsonmapping" ] 11 | { 12 | 13 | 14 | 15 | 16 | 17 | 18 | } 19 | 20 | Property Foo As %String [ SqlFieldName = FOO_NAME ]; 21 | 22 | Property Bar As %String; 23 | 24 | Property Baz As %String; 25 | 26 | Property Another As %String; 27 | 28 | Storage Default 29 | { 30 | 31 | 32 | %%CLASSNAME 33 | 34 | 35 | Foo 36 | 37 | 38 | Bar 39 | 40 | 41 | Baz 42 | 43 | 44 | Another 45 | 46 | 47 | ^UnitTest.A6B07.SamplePersiBF32D 48 | SamplePersistentAdaptedDefaultData 49 | ^UnitTest.A6B07.SamplePersiBF32D 50 | ^UnitTest.A6B07.SamplePersiBF32I 51 | ^UnitTest.A6B07.SamplePersiBF32S 52 | %Library.CacheStorage 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /samples/phonebook/cls/Sample/Phonebook/Model/PhoneNumber.cls: -------------------------------------------------------------------------------- 1 | Class Sample.Phonebook.Model.PhoneNumber Extends (%Persistent, %Populate, %JSON.Adaptor) 2 | { 3 | 4 | Relationship Person As Sample.Phonebook.Model.Person(%JSONINCLUDE = "none") [ Cardinality = parent, Inverse = PhoneNumbers ]; 5 | 6 | Property RowID As %String(%JSONFIELDNAME = "_id", %JSONINCLUDE = "outputonly") [ Calculated, SqlComputeCode = {Set {*} = {%%ID}}, SqlComputed, Transient ]; 7 | 8 | Property PhoneNumber As %String(%JSONFIELDNAME = "number", POPSPEC = "USPhone()"); 9 | 10 | Property Type As %String(%JSONFIELDNAME = "type", VALUELIST = ",Mobile,Home,Office"); 11 | 12 | XData PhoneNumberWithPerson [ XMLNamespace = "http://www.intersystems.com/jsonmapping" ] 13 | { 14 | 15 | 16 | 17 | 18 | 19 | 20 | } 21 | 22 | Storage Default 23 | { 24 | 25 | 26 | %%CLASSNAME 27 | 28 | 29 | PhoneNumber 30 | 31 | 32 | Type 33 | 34 | 35 | {%%PARENT}("PhoneNumbers") 36 | PhoneNumberDefaultData 37 | ^Sample.Phonebook.Model.PersonC("PhoneNumbers") 38 | ^Sample.Phonebo6771.PhoneNumberI 39 | ^Sample.Phonebo6771.PhoneNumberS 40 | %Storage.Persistent 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/Sample/Data/Company.cls: -------------------------------------------------------------------------------- 1 | /// Use or operation of this code is subject to acceptance of the license available in the code repository for this code. 2 | /// This sample persistent class represents a company.
3 | Class UnitTest.AppS.REST.Sample.Data.Company Extends (%Persistent, %Populate, %XML.Adaptor, %JSON.Adaptor) 4 | { 5 | 6 | /// Define an index for Name. 7 | Index NameIdx On Name [ Type = index ]; 8 | 9 | /// Define a unique index for TaxID. 10 | Index TaxIDIdx On TaxID [ Type = index, Unique ]; 11 | 12 | /// The company's name. 13 | Property Name As %String(MAXLEN = 80, POPSPEC = "Company()") [ Required ]; 14 | 15 | /// The company's mission statement. 16 | Property Mission As %String(MAXLEN = 200, POPSPEC = "Mission()"); 17 | 18 | /// The unique Tax ID number for the company. 19 | Property TaxID As %String [ Required ]; 20 | 21 | /// The last reported revenue for the company. 22 | Property Revenue As %Integer; 23 | 24 | /// The Employee objects associated with this Company. 25 | Relationship Employees As UnitTest.AppS.REST.Sample.Data.Employee [ Cardinality = many, Inverse = Company ]; 26 | 27 | Storage Default 28 | { 29 | 30 | 31 | %%CLASSNAME 32 | 33 | 34 | Name 35 | 36 | 37 | Mission 38 | 39 | 40 | TaxID 41 | 42 | 43 | Revenue 44 | 45 | 46 | ^UnitTest.AppS.REST3D03.CompanyD 47 | CompanyDefaultData 48 | ^UnitTest.AppS.REST3D03.CompanyD 49 | ^UnitTest.AppS.REST3D03.CompanyI 50 | ^UnitTest.AppS.REST3D03.CompanyS 51 | %Storage.Persistent 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/Sample/Data/Employee.cls: -------------------------------------------------------------------------------- 1 | /// Use or operation of this code is subject to acceptance of the license available in the code repository for this code. 2 | /// This sample persistent class represents an employee.
3 | Class UnitTest.AppS.REST.Sample.Data.Employee Extends UnitTest.AppS.REST.Sample.Data.Person 4 | { 5 | 6 | /// The employee's job title. 7 | Property Title As %String(MAXLEN = 50, POPSPEC = "Title()"); 8 | 9 | /// The employee's current salary. 10 | Property Salary As %Integer(MAXVAL = 100000, MINVAL = 0); 11 | 12 | /// A character stream containing notes about this employee. 13 | Property Notes As %Stream.GlobalCharacter; 14 | 15 | /// A picture of the employee 16 | Property Picture As %Stream.GlobalBinary; 17 | 18 | /// The company this employee works for. 19 | Relationship Company As UnitTest.AppS.REST.Sample.Data.Company [ Cardinality = one, Inverse = Employees ]; 20 | 21 | /// Writes a .png file containing the picture, if any, of this employee 22 | /// the purpose of this method is to prove that Picture really contains an image 23 | Method WritePicture() 24 | { 25 | if (..Picture="") {quit} 26 | set name=$TR(..Name,".") ; strip off trailing period 27 | set name=$TR(name,", ","__") ; replace commas and spaces 28 | set filename=name_".png" 29 | 30 | set file=##class(%Stream.FileBinary).%New() 31 | set file.Filename=filename 32 | do file.CopyFrom(..Picture) 33 | do file.%Save() 34 | write !, "Generated file: "_filename 35 | } 36 | 37 | Storage Default 38 | { 39 | 40 | "Employee" 41 | 42 | Title 43 | 44 | 45 | Salary 46 | 47 | 48 | Notes 49 | 50 | 51 | Picture 52 | 53 | 54 | Company 55 | 56 | 57 | EmployeeDefaultData 58 | %Storage.Persistent 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/Proxy.cls: -------------------------------------------------------------------------------- 1 | Include %occErrors 2 | 3 | /// Provides REST access to a particular (named) representation of a JSON-enabled persistent class. 4 | Class AppS.REST.Model.Proxy Extends AppS.REST.Model.DBMappedResource [ Abstract, System = 4 ] 5 | { 6 | 7 | Property %instance As %JSON.Adaptor [ Private, Transient ]; 8 | 9 | ClassMethod GetModelFromObject(obj As %Persistent) As AppS.REST.Model.Proxy [ Internal ] 10 | { 11 | Set proxy = ..%New() 12 | Set proxy.%instance = obj 13 | Return proxy 14 | } 15 | 16 | /// Saves the model instance 17 | Method SaveModelInstance(pUserContext As %RegisteredObject) 18 | { 19 | do ..OnBeforeSaveModel(.pUserContext) 20 | $$$ThrowOnError(..%instance.%Save()) 21 | do ..OnAfterSaveModel(.pUserContext) 22 | } 23 | 24 | /// Deletes an instance of this model, based on the identifier pID 25 | ClassMethod DeleteModelInstance(pID As %String) As %Boolean 26 | { 27 | Set tSC = $classmethod(..#SOURCECLASS, "%DeleteId", pID) 28 | If $System.Status.Equals(tSC,$$$DeleteObjectNotFound) { 29 | Quit 0 30 | } 31 | $$$ThrowOnError(tSC) 32 | Quit 1 33 | } 34 | 35 | /// JSONImport imports JSON or dynamic object input into this object.
36 | /// The input argument is either JSON as a string or stream, or a subclass of %DynamicAbstractObject. 37 | Method JSONImport(input) As %Status 38 | { 39 | Quit ..%instance.%JSONImport(.input, ..#JSONMAPPING) 40 | } 41 | 42 | /// Serialize a JSON enabled class as a JSON document and write it to the current device. 43 | Method JSONExport() As %Status 44 | { 45 | Quit ..%instance.%JSONExport(..#JSONMAPPING) 46 | } 47 | 48 | /// Serialize a JSON enabled class as a JSON document and write it to a stream. 49 | Method JSONExportToStream(ByRef export As %Stream.Object) As %Status 50 | { 51 | Quit ..%instance.%JSONExportToStream(.export, ..#JSONMAPPING) 52 | } 53 | 54 | /// Serialize a JSON enabled class as a JSON document and return it as a string. 55 | Method JSONExportToString(ByRef %export As %String) As %Status 56 | { 57 | Quit ..%instance.%JSONExportToString(.%export, ..#JSONMAPPING) 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/Adaptor.cls: -------------------------------------------------------------------------------- 1 | Include %occErrors 2 | 3 | /// A class may extend %Persistent, AppS.REST.Model.Adaptor, and %JSON.Adaptor to grant automatic (but gated) rest exposure of a particular representation. 4 | Class AppS.REST.Model.Adaptor Extends AppS.REST.Model.DBMappedResource [ Abstract, System = 4 ] 5 | { 6 | 7 | /// For an adapted class, the class itself is the source. 8 | Parameter SOURCECLASS As COSEXPRESSION [ Final ] = "$classname()"; 9 | 10 | /// Uses the data from a persistent object to populate the properties of this model. 11 | ClassMethod GetModelFromObject(object As %Persistent) As AppS.REST.Model.Adaptor 12 | { 13 | Return object 14 | } 15 | 16 | /// Saves the model instance 17 | Method SaveModelInstance(pUserContext As %RegisteredObject) 18 | { 19 | Do ..OnBeforeSaveModel(.pUserContext) 20 | $$$ThrowOnError(..%Save()) 21 | Do ..OnAfterSaveModel(.pUserContext) 22 | } 23 | 24 | /// Deletes an instance of this model, based on the identifier pID 25 | ClassMethod DeleteModelInstance(pID As %String) As %Boolean 26 | { 27 | Set tSC = ..%DeleteId(pID) 28 | If $System.Status.Equals(tSC,$$$DeleteObjectNotFound) { 29 | Quit 0 30 | } 31 | $$$ThrowOnError(tSC) 32 | Quit 1 33 | } 34 | 35 | /// JSONImport imports JSON or dynamic object input into this object.
36 | /// The input argument is either JSON as a string or stream, or a subclass of %DynamicAbstractObject. 37 | Method JSONImport(input) As %Status 38 | { 39 | Quit ..%JSONImport(.input, ..#JSONMAPPING) 40 | } 41 | 42 | /// Serialize a JSON enabled class as a JSON document and write it to the current device. 43 | Method JSONExport() As %Status 44 | { 45 | Quit ..%JSONExport(..#JSONMAPPING) 46 | } 47 | 48 | /// Serialize a JSON enabled class as a JSON document and write it to a stream. 49 | Method JSONExportToStream(ByRef export As %Stream.Object) As %Status 50 | { 51 | Quit ..%JSONExportToStream(.export, ..#JSONMAPPING) 52 | } 53 | 54 | /// Serialize a JSON enabled class as a JSON document and return it as a string. 55 | Method JSONExportToString(ByRef %export As %String) As %Status 56 | { 57 | Quit ..%JSONExportToString(.%export, ..#JSONMAPPING) 58 | } 59 | 60 | } 61 | 62 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/SampleUnprotectedClass.cls: -------------------------------------------------------------------------------- 1 | Class UnitTest.AppS.REST.SampleUnprotectedClass Extends (%UnitTest.TestCase, AppS.REST.Model.Resource) 2 | { 3 | 4 | /// Name of the resource at the REST level 5 | /// Subclasses MUST override this 6 | Parameter RESOURCENAME As STRING = "unittest-fake-resource"; 7 | 8 | /// This is very bad in any other case, though there is no business logic for this class, so it's fine. 9 | ClassMethod CheckPermission(pID As %String, pOperation As %String, pUserContext As %RegisteredObject) As %Boolean 10 | { 11 | Quit 1 12 | } 13 | 14 | /// Asserts that the implementation of CheckPermission for this class is located correctly. 15 | Method TestListImplementations() 16 | { 17 | Do ##class(AppS.REST.Auditor).ListSecurityImplementations(.impl,1) 18 | Do $$$AssertEquals(impl($classname(),1),$c(9)_"Quit 1") 19 | } 20 | 21 | /// Asserts that this class is the only non-whitelisted, non-security-protected class. 22 | Method TestThatIAmUnprotected() 23 | { 24 | Set list = ##class(AppS.REST.Auditor).ListUnprotectedClasses(1) 25 | Do $$$AssertEquals($ListLength(list),1,"Only one class was recognized as unprotected.") 26 | Set pointer = 0 27 | Set found = 0 28 | While $ListNext(list,pointer,class) { 29 | If (class '= $classname()) { 30 | Do $$$AssertFailure() 31 | } Else { 32 | Set found = 1 33 | Do $$$AssertEquals(class,$classname(),$classname()_" was treated as an unprotected class.") 34 | } 35 | } 36 | If 'found { 37 | Do $$$AssertFailure($classname()_" was not recgonized as an unprotected class.") 38 | } 39 | } 40 | 41 | /// Tests whitelist behavior. 42 | Method TestWhitelist() 43 | { 44 | Do ##class(AppS.REST.Auditor).WhiteListClass($classname()) 45 | Set list = ##class(AppS.REST.Auditor).ListUnprotectedClasses() 46 | Do $$$AssertEquals(list,"") 47 | Do $$$AssertEquals(##class(AppS.REST.Auditor).IsClassWhiteListed($classname()),1) 48 | Do ##class(AppS.REST.Auditor).RemoveClassFromWhiteList($classname()) 49 | Do $$$AssertEquals(##class(AppS.REST.Auditor).IsClassWhiteListed($classname()),0) 50 | } 51 | 52 | Method TestCompilation() 53 | { 54 | // Gets a bit of test coverage credit for code generation for this class. 55 | Do $System.OBJ.Compile($classname(),"ck-d/nomulticompile") 56 | } 57 | 58 | } 59 | 60 | -------------------------------------------------------------------------------- /cls/AppS/REST/ActionMap.cls: -------------------------------------------------------------------------------- 1 | /// This class is internal to AppS.REST; consumers should not use or reference it directly. 2 | Class AppS.REST.ActionMap Extends %Persistent [ System = 2 ] 3 | { 4 | 5 | Index UniqueByRequest On (ResourceName, ActionName, ActionTarget, HTTPVerb, MediaTypeOrNUL, AcceptsOrNUL) [ Unique ]; 6 | 7 | Index ModelClass On ModelClass; 8 | 9 | Index ImplementationClass On ImplementationClass; 10 | 11 | Property ResourceName As %String(MAXLEN = 128) [ Required ]; 12 | 13 | Property ActionName As %String(MAXLEN = 50) [ Required ]; 14 | 15 | Property ActionTarget As %String(MAXLEN = 8, VALUELIST = ",class,instance") [ Required ]; 16 | 17 | Property HTTPVerb As %String(VALUELIST = ",GET,POST,PUT,DELETE") [ Required ]; 18 | 19 | Property MediaType As %String(MAXLEN = 128); 20 | 21 | Property MediaTypeOrNUL As %String [ Calculated, Required, SqlComputeCode = {Set {*} = $Case({MediaType},"":$c(0),:{MediaType})}, SqlComputed ]; 22 | 23 | Property Accepts As %String(MAXLEN = 128); 24 | 25 | Property AcceptsOrNUL As %String [ Calculated, Required, SqlComputeCode = {Set {*} = $Case({Accepts},"":$c(0),:{Accepts})}, SqlComputed ]; 26 | 27 | Property ModelClass As %Dictionary.CacheClassname [ Required ]; 28 | 29 | Property ImplementationClass As %Dictionary.CacheClassname [ Required ]; 30 | 31 | Storage Default 32 | { 33 | 34 | 35 | %%CLASSNAME 36 | 37 | 38 | ResourceName 39 | 40 | 41 | ActionName 42 | 43 | 44 | ActionTarget 45 | 46 | 47 | HTTPVerb 48 | 49 | 50 | MediaType 51 | 52 | 53 | Accepts 54 | 55 | 56 | ModelClass 57 | 58 | 59 | ImplementationClass 60 | 61 | 62 | ^AppS.REST.ActionMapD 63 | ActionMapDefaultData 64 | ^AppS.REST.ActionMapD 65 | ^AppS.REST.ActionMapI 66 | ^AppS.REST.ActionMapS 67 | %Storage.Persistent 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/Action/Projection.cls: -------------------------------------------------------------------------------- 1 | /// This class is internal to AppS.REST; consumers should not use or reference it directly. 2 | Class AppS.REST.Model.Action.Projection Extends %Projection.AbstractProjection [ System = 3 ] 3 | { 4 | 5 | ClassMethod CreateProjection(classname As %String, ByRef parameters As %String, modified As %String, qstruct) As %Status 6 | { 7 | Set sc = $$$OK 8 | Set initTransLevel = $TLevel 9 | Try { 10 | Set actions = ##class(AppS.REST.Model.Action.Generator).GetActionMetadata(classname) 11 | If (actions.actions.Count() = 0) { 12 | Quit 13 | } 14 | 15 | TSTART 16 | 17 | // Create action handler class 18 | Set targetClass = classname_".Actions" 19 | 20 | Set classDefinition = ##class(%Dictionary.ClassDefinition).%New() 21 | Set classDefinition.Name = targetClass 22 | Set classDefinition.Super = "AppS.REST.Model.Action.Handler" 23 | Set classDefinition.ProcedureBlock = 1 24 | 25 | Set param = ##class(%Dictionary.ParameterDefinition).%New() 26 | Set param.Name = "SOURCECLASS" 27 | Set param.Default = classname 28 | Do classDefinition.Parameters.Insert(param) 29 | 30 | Set dependencies = ##class(AppS.REST.Model.Action.Generator).GetClassDependencies(classname) 31 | Set classDefinition.CompileAfter = $ListToString(dependencies) 32 | Set classDefinition.DependsOn = $ListToString(dependencies) 33 | 34 | $$$ThrowOnError(classDefinition.%Save()) 35 | 36 | Do ..QueueClass(targetClass) 37 | 38 | TCOMMIT 39 | } Catch e { 40 | Set sc = e.AsStatus() 41 | } 42 | 43 | While ($TLevel > initTransLevel) { 44 | TROLLBACK 1 45 | } 46 | Quit sc 47 | } 48 | 49 | ClassMethod RemoveProjection(classname As %String, ByRef parameters As %String, recompile As %Boolean, modified As %String, qstruct) As %Status 50 | { 51 | Set sc = $$$OK 52 | Set initTransLevel = $TLevel 53 | Try { 54 | TSTART 55 | 56 | Set targetClass = classname_".Actions" 57 | If ##class(%Dictionary.ClassDefinition).%ExistsId(targetClass) { 58 | $$$ThrowOnError($System.OBJ.Delete(targetClass,.qstruct)) 59 | } 60 | 61 | &sql(delete from AppS_REST.ActionMap where ModelClass = :classname) 62 | If (SQLCODE < 0) { 63 | Throw ##class(%Exception.SQL).CreateFromSQLCODE(SQLCODE,%msg) 64 | } 65 | 66 | TCOMMIT 67 | } Catch e { 68 | Set sc = e.AsStatus() 69 | } 70 | 71 | While ($TLevel > initTransLevel) { 72 | TROLLBACK 1 73 | } 74 | Quit sc 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## apps-rest 2 | [![Quality Gate Status](https://community.objectscriptquality.com/api/project_badges/measure?project=intersystems_iris_community%2Fapps-rest&metric=alert_status)](https://community.objectscriptquality.com/dashboard?id=intersystems_iris_community%2Fapps-rest) 3 | 4 | --- 5 | 6 | # Deprecation Notice 7 | 8 | This project has been deprecated in favor of its successor, [isc.rest](https://github.com/intersystems/isc-rest), and will not receive further updates. For discussion of migration from apps-rest to isc.rest, see https://github.com/intersystems/isc-rest/discussions/11. 9 | 10 | --- 11 | 12 | # AppS.REST 13 | A framework for building secure REST APIs to existing persistent classes and business logic in the InterSystems IRIS Data Platform. 14 | 15 | ## Getting Started 16 | Note: a minimum platform version of InterSystems IRIS 2018.1 is required. 17 | 18 | ### Installation: ZPM 19 | 20 | If you already have the [ObjectScript Package Manager](https://openexchange.intersystems.com/package/ObjectScript-Package-Manager-2), installation is as easy as: 21 | ``` 22 | zpm "install apps.rest" 23 | ``` 24 | 25 | ### Tutorial 26 | For a step-by-step tutorial, see [AppS.REST Tutorial and Sample Application: Contact List](https://github.com/intersystems/apps-rest/blob/master/docs/sample-phonebook.md). 27 | 28 | ## User Guide 29 | See [AppS.REST User Guide](https://github.com/intersystems/apps-rest/blob/master/docs/user-guide.md). 30 | 31 | ## Support 32 | If you find a bug or would like to request an enhancement, [report an issue](https://github.com/intersystems/apps-rest/issues/new). If you have a question, feel free to post it on the [InterSystems Developer Community](https://community.intersystems.com/). 33 | 34 | ## Contributing 35 | Please read [contributing](https://github.com/intersystems/apps-rest/blob/master/CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 36 | 37 | ## Versioning 38 | We use [SemVer](http://semver.org/) for versioning. Declare your dependencies using the community package manager for the appropriate level of risk. 39 | 40 | ## Authors 41 | * **Tim Leavitt** - *Initial implementation* - [timleavitt](http://github.com/timleavitt) 42 | 43 | See also the list of [contributors](https://github.com/intersystems/apps-rest/graphs/contributors) who participated in this project. 44 | 45 | ## License 46 | This project is licensed under the MIT License - see the [LICENSE](https://github.com/intersystems/apps-rest/blob/master/LICENSE) file for details. 47 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/Resource.cls: -------------------------------------------------------------------------------- 1 | /// Base class for all REST resources 2 | Class AppS.REST.Model.Resource Extends (%RegisteredObject, AppS.REST.Model.ISerializable) [ Abstract, System = 3 ] 3 | { 4 | 5 | /// Name of the resource at the REST level 6 | /// Subclasses MUST override this 7 | Parameter RESOURCENAME As STRING [ Abstract ]; 8 | 9 | /// Name of the parent resource, if any (defaults to empty string) 10 | /// Subclasses MAY override this 11 | Parameter PARENT As STRING; 12 | 13 | /// Content-Type implemented in this class 14 | /// Subclasses MAY override this 15 | Parameter MEDIATYPE As STRING = "application/json"; 16 | 17 | /// Maintains data in AppS.REST.ResourceMap for this class. 18 | Projection ResourceMap As AppS.REST.Model.ResourceMapProjection; 19 | 20 | /// Returns an instance of this model, based on the arguments supplied. 21 | ClassMethod GetModelInstance(args...) As AppS.REST.Model.Resource [ Abstract ] 22 | { 23 | } 24 | 25 | /// Saves the model instance 26 | Method SaveModelInstance(pUserContext As %RegisteredObject) [ Abstract ] 27 | { 28 | } 29 | 30 | /// Deletes an instance of this model, based on the identifier pID 31 | ClassMethod DeleteModelInstance(pID As %String) As %Boolean [ Abstract ] 32 | { 33 | } 34 | 35 | /// Invokes a specified action on this model 36 | ClassMethod InvokeModelAction(pAction As %String, pArgs...) [ Abstract ] 37 | { 38 | } 39 | 40 | /// Invokes a specified action on an instance of this model 41 | Method InvokeInstanceAction(pAction As %String, pArgs...) [ Abstract ] 42 | { 43 | } 44 | 45 | /// Called by the handler when serving plural get requests.

46 | /// In the simplest case, the Resrouce is a Proxy and params are just query parameters for querying a table. 47 | ClassMethod GetCollection(ByRef params) [ Abstract ] 48 | { 49 | } 50 | 51 | /// Checks the user's permission for a particular operation on a particular record. 52 | /// pOperation may be one of: 53 | /// CREATE 54 | /// READ 55 | /// UPDATE 56 | /// DELETE 57 | /// QUERY 58 | /// ACTION: 59 | /// pUserContext is supplied by GetUserContext 60 | ClassMethod CheckPermission(pID As %String, pOperation As %String, pUserContext As %RegisteredObject) As %Boolean 61 | { 62 | Quit 0 63 | } 64 | 65 | /// Defines a mapping of actions available for this model class to the associated methods and arguments. 66 | XData ActionMap [ XMLNamespace = "http://www.intersystems.com/apps/rest/action" ] 67 | { 68 | 69 | 70 | } 71 | 72 | Projection Actions As AppS.REST.Model.Action.Projection; 73 | 74 | } 75 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/QueryResult.cls: -------------------------------------------------------------------------------- 1 | /// This class is internal to AppS.REST; consumers should not need to use it directly. 2 | Class AppS.REST.Model.QueryResult Extends (%RegisteredObject, AppS.REST.Model.ISerializable) [ System = 4 ] 3 | { 4 | 5 | Property rows As list Of AppS.REST.Model.Resource; 6 | 7 | ClassMethod FromClassQuery(pModelClass As %Dictionary.CacheClassname, pQueryClass As %Dictionary.CacheClassname, pQueryName As %Dictionary.CacheIdentifier, pArgs...) As AppS.REST.Model.QueryResult [ Internal ] 8 | { 9 | Set instance = ..%New() 10 | // Use ResultSet rather than %SQL.Statement to support non-SQLProc class queries 11 | Set result = ##class(%Library.ResultSet).%New(pQueryClass _ ":" _ pQueryName) 12 | $$$ThrowOnError(result.%Execute(pArgs...)) 13 | If (result.%SQLCODE < 0) { 14 | Throw ##class(%Exception.SQL).CreateFromSQLCODE(result.%SQLCODE, result.%Message) 15 | } 16 | Set useResult = +$Parameter(pModelClass, "ConstructFromResultRow") 17 | While result.%Next(.sc) { 18 | $$$ThrowOnError(sc) 19 | Set resource = $classmethod(pModelClass, "GetModelInstance", $Select(useResult:result, 1:result.%GetData(1))) 20 | If $IsObject(resource) { 21 | Do instance.rows.Insert(resource) 22 | } 23 | } 24 | $$$ThrowOnError(sc) 25 | Quit instance 26 | } 27 | 28 | /// Serialize a JSON enabled class as a JSON document and write it to the current device. 29 | Method JSONExport() As %Status 30 | { 31 | Set sc = ..JSONExportToStream(.stream) 32 | If $$$ISOK(sc) { 33 | Do stream.OutputToDevice() 34 | } 35 | Quit sc 36 | } 37 | 38 | /// Serialize a JSON enabled class as a JSON document and write it to a stream. 39 | Method JSONExportToStream(ByRef export As %Stream.Object) As %Status 40 | { 41 | Quit ..JSONExportInternal(1,.export) 42 | } 43 | 44 | /// Serialize a JSON enabled class as a JSON document and return it as a string. 45 | Method JSONExportToString(ByRef export As %String) As %Status 46 | { 47 | Quit ..JSONExportInternal(0,.export) 48 | } 49 | 50 | Method JSONExportInternal(pStreamMode As %Boolean = 1, Output export) As %Status 51 | { 52 | Try { 53 | Set buffer = ##class(AppS.Util.Buffer).%New() 54 | $$$ThrowOnError(buffer.BeginCaptureOutput()) 55 | Write "[" 56 | For index=1:1:..rows.Count() { 57 | If (index > 1) { 58 | Write "," 59 | } 60 | $$$ThrowOnError(..rows.GetAt(index).JSONExport()) 61 | } 62 | Write "]" 63 | If (pStreamMode) { 64 | $$$ThrowOnError(buffer.ReadToStream(.export)) 65 | } Else { 66 | $$$ThrowOnError(buffer.ReadToString(.export)) 67 | } 68 | $$$ThrowOnError(buffer.EndCaptureOutput()) 69 | } Catch e { 70 | Kill export 71 | Set sc = e.AsStatus() 72 | } 73 | Quit sc 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /samples/phonebook/cls/Sample/Phonebook/Model/Person.cls: -------------------------------------------------------------------------------- 1 | Class Sample.Phonebook.Model.Person Extends (%Persistent, %Populate, %JSON.Adaptor, AppS.REST.Model.Adaptor) 2 | { 3 | 4 | Parameter RESOURCENAME = "contact"; 5 | 6 | Property RowID As %String(%JSONFIELDNAME = "_id", %JSONINCLUDE = "outputonly") [ Calculated, SqlComputeCode = {Set {*} = {%%ID}}, SqlComputed, Transient ]; 7 | 8 | Property Name As %String(%JSONFIELDNAME = "name"); 9 | 10 | Relationship PhoneNumbers As Sample.Phonebook.Model.PhoneNumber(%JSONFIELDNAME = "phones", %JSONINCLUDE = "outputonly", %JSONREFERENCE = "object") [ Cardinality = children, Inverse = Person ]; 11 | 12 | /// Checks the user's permission for a particular operation on a particular record. 13 | /// pOperation may be one of: 14 | /// CREATE 15 | /// READ 16 | /// UPDATE 17 | /// DELETE 18 | /// QUERY 19 | /// ACTION: 20 | /// pUserContext is supplied by GetUserContext 21 | ClassMethod CheckPermission(pID As %String, pOperation As %String, pUserContext As AppS.REST.Authentication.PlatformUser) As %Boolean 22 | { 23 | Quit (pOperation = "QUERY") || (pOperation = "READ") || (pOperation = "CREATE") || (pOperation = "UPDATE") || 24 | (pOperation = "ACTION:add-phone") || (pOperation = "ACTION:find-by-phone") 25 | } 26 | 27 | Method AddPhoneNumber(phoneNumber As Sample.Phonebook.Model.PhoneNumber) As Sample.Phonebook.Model.Person 28 | { 29 | Set phoneNumber.Person = $This 30 | $$$ThrowOnError(phoneNumber.%Save()) 31 | Quit $This 32 | } 33 | 34 | Query FindByPhone(phoneFragment As %String) As %SQLQuery 35 | { 36 | select distinct Person 37 | from Sample_Phonebook_Model.PhoneNumber 38 | where $Translate(PhoneNumber,' -+()') [ $Translate(:phoneFragment,' -+()') 39 | } 40 | 41 | XData ActionMap [ XMLNamespace = "http://www.intersystems.com/apps/rest/action" ] 42 | { 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | } 53 | 54 | Storage Default 55 | { 56 | 57 | 58 | %%CLASSNAME 59 | 60 | 61 | Name 62 | 63 | 64 | ^Sample.Phonebook.Model.PersonD 65 | PersonDefaultData 66 | ^Sample.Phonebook.Model.PersonD 67 | ^Sample.Phonebook.Model.PersonI 68 | ^Sample.Phonebook.Model.PersonS 69 | %Storage.Persistent 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/QueryBuilder.cls: -------------------------------------------------------------------------------- 1 | Class UnitTest.AppS.REST.QueryBuilder Extends %UnitTest.TestCase 2 | { 3 | 4 | Method TestCompiling() 5 | { 6 | Do $$$AssertStatusOK($System.OBJ.Compile("UnitTest.AppS.REST.SamplePersistentAdapted","ck/nomulticompile")) 7 | } 8 | 9 | Method TestValidEverything() 10 | { 11 | Set p("someField[noteq]",1) = 2 // someField is the JSON alias for 'Foo' 12 | Set p("Bar[isnull]",1) = "" 13 | Do ..GetQueryWrapper(.p,.query,.params,.prepareStatus,.exception) 14 | If $$$AssertEquals($Data(exception),0) { 15 | Do $$$AssertEquals(query,"select ID from UnitTest_AppS_REST.SamplePersistentAdapted where 1=1 and Bar is null and not FOO_NAME = ?") 16 | Do $$$AssertEquals(params,1) 17 | Do $$$AssertEquals(params(1),2) 18 | Do $$$AssertStatusOK(prepareStatus) 19 | } Else { 20 | Do $$$AssertFailure("Exception occured: "_exception.DisplayString()) 21 | } 22 | } 23 | 24 | Method TestIllegalColumnExpression() 25 | { 26 | Set p("Baz union all select 1 -- [eq]",1) = "42" 27 | Do ..GetQueryWrapper(.p,.query,.params,.prepareStatus,.exception) 28 | If $$$AssertEquals($Data(exception),1) { 29 | Do $$$AssertEquals($classname(exception),"AppS.REST.Exception.InvalidColumnException") 30 | } Else { 31 | Do $$$AssertFailure("Exception should have been thrown.") 32 | } 33 | } 34 | 35 | Method TestInvalidColumnName() 36 | { 37 | // For now, a runtime error preparing the query. 38 | Set p("Qux[eq]",1) = "42" 39 | Do ..GetQueryWrapper(.p,.query,.params,.prepareStatus,.exception) 40 | If $$$AssertEquals($Data(exception),1) { 41 | Do $$$AssertEquals($classname(exception),"AppS.REST.Exception.InvalidColumnException") 42 | } Else { 43 | Do $$$AssertFailure("Exception should have been thrown.") 44 | } 45 | } 46 | 47 | Method TestInvalidOrderBy() 48 | { 49 | Set p("$orderBy",1) = "baz union all select 1" 50 | Do ..GetQueryWrapper(.p,.query,.params,.prepareStatus,.exception) 51 | If $$$AssertEquals($Data(exception),1) { 52 | Do $$$AssertEquals($classname(exception),"AppS.REST.Exception.InvalidColumnException") 53 | } Else { 54 | Do $$$AssertFailure("Exception should have been thrown.") 55 | } 56 | } 57 | 58 | Method GetQueryWrapper(ByRef URLParams, Output query As %String, Output queryParams, Output prepareStatus As %Status, Output exception As %Exception.AbstractException) 59 | { 60 | Kill query, queryParams, prepareStatus, exception 61 | Try { 62 | Set query = ##class(AppS.REST.QueryGenerator).GetQuery( 63 | "UnitTest.AppS.REST.SamplePersistentAdapted", 64 | ##class(UnitTest.AppS.REST.SamplePersistentAdapted).GetProxyColumnList(), 65 | ##class(UnitTest.AppS.REST.SamplePersistentAdapted).#IndexToUse, 66 | .URLParams, 67 | .queryParams) 68 | } Catch exception { 69 | Return 70 | } 71 | 72 | Set tStatement = ##class(%SQL.Statement).%New() 73 | Set prepareStatus = tStatement.%Prepare(query) 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /cls/AppS/REST/Auditor.cls: -------------------------------------------------------------------------------- 1 | /// This class provides methods for detecting unprotected classes on the current system. 2 | /// It's good to build this into a unit test to make sure you're enforcing security correctly. 3 | Class AppS.REST.Auditor 4 | { 5 | 6 | /// Returns a $ListBuild list of non-whitelisted, non-protected classes.
7 | /// @API.Method 8 | ClassMethod ListUnprotectedClasses(pVerbose As %Boolean = 0) As %List 9 | { 10 | Set list = "" 11 | Set result = ..GetAllResourceClasses() 12 | While result.%Next(.sc) { 13 | $$$ThrowOnError(sc) 14 | Try { 15 | Set class = result.%GetData(1) 16 | If ..IsClassWhiteListed(class) { 17 | Continue 18 | } 19 | 20 | // Simple way to detect implementations that always return true: 21 | // Define no parameters and see if we return 1. 22 | If $classmethod(class,"CheckPermission") { 23 | Set list = list_$lb(class) 24 | If pVerbose { 25 | Write "Unprotected REST model class: ",class,! 26 | } 27 | } 28 | } Catch e { 29 | // This is actually good - probably an 30 | } 31 | } 32 | $$$ThrowOnError(sc) 33 | Quit list 34 | } 35 | 36 | /// Lists all security implementations for resources defined in this environment.
37 | /// Outputs them to the current device if pVerbose is true.
38 | /// Also returns implementation details in pImplementations.
39 | /// @API.Method 40 | ClassMethod ListSecurityImplementations(Output pImplementations, pVerbose As %Boolean = 0) 41 | { 42 | Set result = ..GetAllResourceClasses() 43 | If pVerbose { 44 | Write ! 45 | } 46 | While result.%Next(.sc) { 47 | $$$ThrowOnError(sc) 48 | Set class = result.%GetData(1) 49 | Set implOrigin = $$$comMemberKeyGet(class,$$$cCLASSmethod,"CheckPermission",$$$cMETHorigin) 50 | If (implOrigin = "") { 51 | Set implOrigin = class 52 | } 53 | Merge pImplementations(class) = ^oddDEF(implOrigin,$$$cCLASSmethod,"CheckPermission",$$$cMETHimplementation) 54 | If pVerbose { 55 | Write class," (",$parameter(class,"RESOURCENAME"),")",! 56 | Write "CheckPermission implementation" 57 | If (implOrigin '= class) { 58 | Write ", inherited from ",implOrigin 59 | } 60 | Write ": ",! 61 | For line=1:1:$Get(pImplementations(class)) { 62 | Write pImplementations(class,line),! 63 | } 64 | Write ! 65 | } 66 | } 67 | $$$ThrowOnError(sc) 68 | } 69 | 70 | /// Adds a class to the whitelist of classes that may have security unimplemented
71 | /// @API.Method 72 | ClassMethod WhiteListClass(pClass As %Dictionary.CacheClassname) 73 | { 74 | Set ^Config("AppS.REST","WhiteList",pClass) = $ListBuild($username) 75 | } 76 | 77 | /// Removes a class from the whitelist of classes that may have security unimplemented
78 | /// @API.Method 79 | ClassMethod RemoveClassFromWhiteList(pClass As %Dictionary.CacheClassname) 80 | { 81 | Kill ^Config("AppS.REST","WhiteList",pClass) 82 | } 83 | 84 | /// Returns true if a class is whitelisted
85 | /// @API.Method 86 | ClassMethod IsClassWhiteListed(pClass As %Dictionary.CacheClassname) As %Boolean 87 | { 88 | Quit $Data(^Config("AppS.REST","WhiteList",pClass))#2 89 | } 90 | 91 | ClassMethod GetAllResourceClasses() As %SQL.StatementResult [ Internal, Private ] 92 | { 93 | Set tResult = ##class(%SQL.Statement).%ExecDirect(,"select distinct %exact ModelClass from AppS_REST.ResourceMap") 94 | If (tResult.%SQLCODE < 0) { 95 | Throw ##class(%Exception.SQL).CreateFromSQLCODE(tResult.%SQLCODE,tResult.%Message) 96 | } 97 | Quit tResult 98 | } 99 | 100 | } 101 | 102 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/Sample/Data/Utils.cls: -------------------------------------------------------------------------------- 1 | Include %occInclude 2 | 3 | /// Use or operation of this code is subject to acceptance of the license available in the code repository for this code. 4 | /// This class contains a method to generate data for Sample package 5 | Class UnitTest.AppS.REST.Sample.Data.Utils 6 | { 7 | 8 | /// Invoke this method to set up the data for these classes. 9 | /// Create one company for every five people. 10 | ClassMethod Generate(personCount As %Integer = 100) 11 | { 12 | // Make sure we always have at least 1 person 13 | if (personCount < 1) { 14 | set personCount=1 15 | } 16 | 17 | // Never use %KillExtent() in a real application 18 | do ##class(UnitTest.AppS.REST.Sample.Data.Company).%KillExtent() 19 | do ##class(UnitTest.AppS.REST.Sample.Data.Person).%KillExtent() 20 | do ##class(UnitTest.AppS.REST.Sample.Data.Employee).%KillExtent() 21 | //do ##class(Sample.Vendor).%KillExtent() ; doesn't work with %Storage.SQL 22 | kill ^Sample.DataD,^Sample.VendorI,^Sample.VendorD // VendorD has the index counter - want to restart at 1. 23 | 24 | set companyCount= personCount \ 5 25 | if (companyCount < 1) { 26 | set companyCount=1 27 | } 28 | 29 | do ##class(UnitTest.AppS.REST.Sample.Data.Company).Populate(companyCount) 30 | do ##class(UnitTest.AppS.REST.Sample.Data.Person).Populate(personCount) 31 | do ##class(UnitTest.AppS.REST.Sample.Data.Employee).Populate(personCount,,,,2) 32 | do ##class(UnitTest.AppS.REST.Sample.Data.Vendor).Populate(personCount) 33 | 34 | // Specify values for stream properties in Sample.Employee 35 | // do this for the first lucky 10 employees, for reasons of space 36 | 37 | set e1=personCount+1 ; ID of the first employee 38 | for i=e1:1:e1+9 { 39 | set employee=##class(UnitTest.AppS.REST.Sample.Data.Employee).%OpenId(i) 40 | set firstname=$PIECE(employee.Name,",",2) 41 | set firstname=$PIECE(firstname," ",1) 42 | set text=firstname_" used to work at "_##class(%PopulateUtils).Company() 43 | _" as a(n) "_##class(%PopulateUtils).Title() 44 | 45 | do employee.Notes.Write(text) 46 | 47 | // Add a stock picture (yes, all these employees look alike) 48 | do employee.Picture.Write($System.Encryption.Base64Decode( 49 | "iVBORw0KGgoAAAANSUhEUgAAABIAAAARCAIAAABfOGuuAAAAK3RFWHRDcmVhdGlvbiBUaW1lAFRo"_ 50 | "dSA2IEphbiAyMDExIDE2OjIwOjU4IC0wNTAw73VlcAAAAAd0SU1FB9sBBhUWCKSIbF4AAAAJcEhZ"_ 51 | "cwAACxIAAAsSAdLdfvwAAAAEZ0FNQQAAsY8L/GEFAAAC1klEQVR42l2TyW/TQBSHZ7MdO3HsZmub"_ 52 | "tKnUTaWlKggEhwoJOCAh9W/ljjhxAEERbWmhFaVruiXYbpw43sYzHhyo2N5tpPfNfPq9edB1XfBv"_ 53 | "CQGYEClgEEAEEIYIwv9aAPn7wAXwKLcSZidRhK9kFNRIroTKGjIVLP9N/8FiDo+9dJdefOODTlBN"_ 54 | "MFCQO19s3ZaVYtIw0fyoVpUw/geLGdiyo3fBJa+2GNZVovth3YnmfbaLzA/TeP3k+mKRPWoW6wSj"_ 55 | "G4yn4ND1Xtq7tGLN5+vjuAkU7ZMn7Qjk0LGDRJspxGp4+LFNVbw2ppcz2SHaC5P19n6PbFVMboDK"_ 56 | "glRZyuWbCtQIZ0hcc8VLSQ4ODu3XO1dfKGcZgrLcOp6353x1qR0kkZs6nTi2YuhSGFMEOIQCHThn"_ 57 | "b1utIOltnzh9f4gRxkXb7bcHXUuGzrlSkUunsiIhdhYC2xcRSFJGnU4TWflxbS/wW5YbVoo5wpjo"_ 58 | "9lLnfMrrjTJzqquVLxSIcUzTLKeEoYhxRbNLNWiDVFjWteP6YtIkAAIey0lXSXxCYxGP+EH2WBZX"_ 59 | "KlKW5KTew5nO4yVvAtq9LnmR2Qs6lCQYFBVZiiQQXwmaJoMRkJfBEEsh9RpTGyuFzhiEqQ8vLS5x"_ 60 | "tVRQsySJRFC9pEwaoaG+DXjx8HQp4qbASHCOmQvI/h6xNlxRkYTncQ1NVkZ0COFwbhM19eGcfjQI"_ 61 | "pMJJaF8ffZ7hQR6wNGXRaUd2jXzk4ca01xgtP19ZNgq5m3EbuvRgYfb0zaIVOM2x47DrXu7WWNvI"_ 62 | "PL3vWiJrmo5E1Vy4s3z31pws4xsMITjbrK7Rp6822Ym/XTZZqHKey0yRLBFZwmZZWV1Yenb/3i/D"_ 63 | "P38yu2NxukHgk/fbo5vn3zlJ6IjIdkZVSb2u373XWF2dmxwvDQP8WfD3vnHOgyDoe77jDKyO3+/H"_ 64 | "CAHDUKo1vVTSi0VNVVWE0K/mHyUqfH/CYKtlAAAAAElFTkSuQmCC")) 65 | 66 | do employee.%Save() 67 | } 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/ResourceMapProjection.cls: -------------------------------------------------------------------------------- 1 | /// This class is internal to AppS.REST; consumers should not use or reference it directly. 2 | Class AppS.REST.Model.ResourceMapProjection Extends %Projection.AbstractProjection [ System = 3 ] 3 | { 4 | 5 | ClassMethod CreateProjection(classname As %String, ByRef parameters As %String, modified As %String, qstruct) As %Status 6 | { 7 | // Skip abstract classes. 8 | If $$$comClassKeyGet(classname,$$$cCLASSabstract) { 9 | Quit $$$OK 10 | } 11 | Set sc = $$$OK 12 | Try { 13 | Set resourceName = $Parameter(classname,"RESOURCENAME") 14 | Set mediaType = $Parameter(classname,"MEDIATYPE") 15 | 16 | // For each resource, there is exactly one Proxy class per mediaType. 17 | // Therefore, if we already have a mapping for this resourceName and mediaType, make sure 18 | // the classname of the class we're compiling matches what we have on record for this (resourceName, mediaType) pair. 19 | If ##class(AppS.REST.ResourceMap).ModelClassExists(classname,.id) { 20 | Set map = ##class(AppS.REST.ResourceMap).%OpenId(id,,.sc) 21 | If (map.ResourceName '= resourceName) || (map.MediaType '= mediaType) { 22 | Set sc = ##class(AppS.REST.ResourceMap).%DeleteId(id) 23 | $$$ThrowOnError(sc) 24 | } 25 | } 26 | If ##class(AppS.REST.ResourceMap).IDKEYExists(resourceName,mediaType,.id) { 27 | Set map = ##class(AppS.REST.ResourceMap).%OpenId(id,,.sc) 28 | $$$ThrowOnError(sc) 29 | If map.ModelClass '= classname { 30 | Set sc = $$$ERROR($$$GeneralError,$$$FormatText("Resource '%1', media type '%2' is already in use by class %3",resourceName,mediaType,map.ModelClass)) 31 | $$$ThrowStatus(sc) 32 | } 33 | } Else { 34 | // If we don't yet have a mapping for this (resourceName, mediaType) pair, create one and populate its fields appropriately 35 | Set map = ##class(AppS.REST.ResourceMap).%New() 36 | } 37 | Set map.ResourceName = resourceName 38 | Set map.MediaType = mediaType 39 | Set map.ModelClass = classname 40 | $$$ThrowOnError(map.%Save()) 41 | } Catch e { 42 | Set sc = e.AsStatus() 43 | } 44 | Quit sc 45 | } 46 | 47 | ClassMethod RemoveProjection(classname As %String, ByRef parameters As %String, recompile As %Boolean, modified As %String, qstruct) As %Status 48 | { 49 | Set sc = $$$OK 50 | // Don't actually remove if it's a recompile and the class still has the projection 51 | // (This avoids interruption in API availability during compilation.) 52 | Set remove = '(recompile && ..ClassHasThisProjection(classname)) 53 | If remove { 54 | If ##class(AppS.REST.ResourceMap).ModelClassExists(classname,.id) { 55 | Set sc = ##class(AppS.REST.ResourceMap).%DeleteId(id) 56 | } 57 | } 58 | Quit sc 59 | } 60 | 61 | /// Helper method to determine if the class supplied has this projection in its current definition 62 | /// (*NOT* compiled class metadata, as that has not yet been updated when RemoveProjection is run) 63 | ClassMethod ClassHasThisProjection(classname As %Dictionary.CacheClassname) As %Boolean 64 | { 65 | // At this stage, assume the class definition is up-to-date, 66 | // but the compiled class metadata is not yet. 67 | Set found = 0 68 | 69 | // Find defined projections: 70 | Set projection = "" 71 | For { 72 | Set projection = $$$defMemberNext(classname,$$$cCLASSprojection,projection) 73 | If (projection = "") { 74 | Quit 75 | } 76 | If $$$defMemberKeyGet(classname,$$$cCLASSprojection,projection,$$$cPROJtype) = $ClassName() { 77 | Set found = 1 78 | } 79 | } 80 | 81 | If 'found { 82 | // Find inherited projections: 83 | Set supers = $ListFromString($$$defClassKeyGet(classname,$$$cCLASSsuper)) 84 | Set pointer = 0 85 | While $ListNext(supers,pointer,super) { 86 | Set found = found || ..ClassHasThisProjection(super) 87 | If (found) { 88 | Quit 89 | } 90 | } 91 | } 92 | 93 | Quit found 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/Sample/Model/Person.cls: -------------------------------------------------------------------------------- 1 | Class UnitTest.AppS.REST.Sample.Model.Person Extends AppS.REST.Model.Proxy [ DependsOn = UnitTest.AppS.REST.Sample.Data.Person ] 2 | { 3 | 4 | /// The class to which this class provides REST access. It must extend %Persistent and have its %JSONENABLED class parameter set to 1 (e.g., by extending %JSON.Adaptor). 5 | /// Subclasses must override this parameter. 6 | Parameter SOURCECLASS As STRING = "UnitTest.AppS.REST.Sample.Data.Person"; 7 | 8 | /// The JSON mapping of the related JSON-enabled class to use. 9 | /// Defaults to empty (the default mapping for the associated class). 10 | Parameter JSONMAPPING As STRING = "LimitedInfo"; 11 | 12 | /// Name of the resource at the REST level 13 | /// Subclasses MUST override this 14 | Parameter RESOURCENAME As STRING = "person"; 15 | 16 | /// Permits READ and QUERY access only. 17 | ClassMethod CheckPermission(pID As %String, pOperation As %String, pUserContext As UnitTest.AppS.REST.Sample.UserContext) As %Boolean 18 | { 19 | If pUserContext.IsAdmin { 20 | // An admin can do anything. 21 | Quit 1 22 | } 23 | If (pUserContext.Username = ##class(UnitTest.AppS.REST.Sample.Data.Person).UsernameGetStored(pID)) { 24 | // The current user can do anything to their own record except delete it. 25 | Quit (pOperation '= "DELETE") 26 | } 27 | Quit (pOperation = "READ") || (pOperation = "QUERY") 28 | } 29 | 30 | /// Defines a mapping of actions available for this model class to the associated methods and arguments. 31 | XData ActionMap [ XMLNamespace = "http://www.intersystems.com/apps/rest/action" ] 32 | { 33 | 34 | 35 | 37 | 38 | 39 | 40 | 41 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | } 66 | 67 | ClassMethod UpdateHomeAddress(pID As %String, pAddress As UnitTest.AppS.REST.Sample.Data.Address) As UnitTest.AppS.REST.Sample.Data.Address 68 | { 69 | Set person = ##class(UnitTest.AppS.REST.Sample.Data.Person).%OpenId(pID,,.sc) 70 | $$$ThrowOnError(sc) 71 | Set person.Home = pAddress 72 | $$$ThrowOnError(person.%Save()) 73 | Quit person.Home 74 | } 75 | 76 | ClassMethod UpdateOfficeAddress(pID As %String, pAddress As UnitTest.AppS.REST.Sample.Data.Address) As UnitTest.AppS.REST.Sample.Model.Person 77 | { 78 | Set person = ##class(UnitTest.AppS.REST.Sample.Data.Person).%OpenId(pID,,.sc) 79 | $$$ThrowOnError(sc) 80 | Set person.Office = pAddress 81 | $$$ThrowOnError(person.%Save()) 82 | Quit ..GetModelInstance(pID) 83 | } 84 | 85 | ClassMethod Ping(pObject As %DynamicAbstractObject) As %DynamicAbstractObject 86 | { 87 | Quit pObject 88 | } 89 | 90 | Method %Id() 91 | { 92 | // Workaround for needing more generic source="id" support. 93 | Quit ..%instance.%Id() 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/Sample/Data/Person.cls: -------------------------------------------------------------------------------- 1 | /// Use or operation of this code is subject to acceptance of the license available in the code repository for this code. 2 | /// This sample persistent class represents a person. 3 | Class UnitTest.AppS.REST.Sample.Data.Person Extends (%Persistent, %Populate, %XML.Adaptor, %JSON.Adaptor) 4 | { 5 | 6 | Parameter EXTENTQUERYSPEC = "Name,SSN,Home.City,Home.State"; 7 | 8 | /// Define a unique index for SSN. 9 | Index SSNKey On SSN [ Type = index, Unique ]; 10 | 11 | /// Define an index for Name. 12 | Index NameIDX On Name [ Data = Name ]; 13 | 14 | /// Define an index for embedded object property ZipCode. 15 | Index ZipCode On Home.Zip [ Type = bitmap ]; 16 | 17 | /// Person's name. 18 | Property Name As %String(POPSPEC = "Name()") [ Required ]; 19 | 20 | /// Person's Social Security number. This is validated using pattern match. 21 | Property SSN As %String(PATTERN = "3N1""-""2N1""-""4N"); 22 | 23 | /// Person's Date of Birth. 24 | Property DOB As %Date(POPSPEC = "Date()"); 25 | 26 | /// Person's home address. This uses an embedded object. 27 | Property Home As UnitTest.AppS.REST.Sample.Data.Address; 28 | 29 | /// Person's office address. This uses an embedded object. 30 | Property Office As UnitTest.AppS.REST.Sample.Data.Address; 31 | 32 | /// Person's spouse. This is a reference to another persistent object. 33 | Property Spouse As UnitTest.AppS.REST.Sample.Data.Person; 34 | 35 | /// A collection of strings representing the person's favorite colors. 36 | Property FavoriteColors As list Of %String(JAVATYPE = "java.util.List", POPSPEC = "ValueList("",Red,Orange,Yellow,Green,Blue,Purple,Black,White""):2"); 37 | 38 | /// Person's age.
39 | /// This is a calculated field whose value is derived from DOB. 40 | Property Age As %Integer [ Calculated, SqlComputeCode = { Set {Age}=##class(UnitTest.AppS.REST.Sample.Data.Person).CurrentAge({DOB}) 41 | }, SqlComputed, SqlComputeOnChange = DOB ]; 42 | 43 | /// This class method calculates a current age given a date of birth date. 44 | /// This method is used by the Age calculated field. 45 | ClassMethod CurrentAge(date As %Date = "") As %Integer [ CodeMode = expression ] 46 | { 47 | $Select(date="":"",1:($ZD($H,8)-$ZD(date,8)\10000)) 48 | } 49 | 50 | /// A sample class query that defines a result set that returns Person data 51 | /// ordered by Name.
52 | /// This query can be used within another method (using 53 | /// dynamic SQL), or it can be used from Java.
54 | /// This query is also accessible from JDBC and/or ODBC as the SQL stored procedure 55 | /// SP_Sample_By_Name. 56 | Query ByName(name As %String = "") As %SQLQuery(CONTAINID = 1, SELECTMODE = "RUNTIME") [ SqlName = SP_Sample_By_Name, SqlProc ] 57 | { 58 | SELECT ID, Name, DOB, SSN 59 | FROM UnitTest_AppS_REST_Sample_Data.Person 60 | WHERE (Name %STARTSWITH :name) 61 | ORDER BY Name 62 | } 63 | 64 | XData LimitedInfo [ XMLNamespace = "http://www.intersystems.com/jsonmapping" ] 65 | { 66 | 67 | 68 | 69 | 70 | 71 | 72 | } 73 | 74 | Storage Default 75 | { 76 | 77 | 78 | %%CLASSNAME 79 | 80 | 81 | Name 82 | 83 | 84 | SSN 85 | 86 | 87 | DOB 88 | 89 | 90 | Home 91 | 92 | 93 | Office 94 | 95 | 96 | Spouse 97 | 98 | 99 | FavoriteColors 100 | 101 | 102 | ^UnitTest.AppS.REST3D03.PersonD 103 | PersonDefaultData 104 | ^UnitTest.AppS.REST3D03.PersonD 105 | ^UnitTest.AppS.REST3D03.PersonI 106 | ^UnitTest.AppS.REST3D03.PersonS 107 | %Storage.Persistent 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # Continuous integration workflow 2 | name: CI 3 | 4 | # Controls when the action will run. Triggers the workflow on push or pull request 5 | # events in all branches 6 | on: [push, pull_request] 7 | 8 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 9 | jobs: 10 | # This workflow contains a single job called "build" 11 | build: 12 | # The type of runner that the job will run on 13 | runs-on: ubuntu-latest 14 | 15 | env: 16 | # ** FOR GENERAL USE, LIKELY NEED TO CHANGE: ** 17 | package: apps.rest 18 | container_image: intersystemsdc/iris-community:latest 19 | 20 | # ** FOR GENERAL USE, MAY NEED TO CHANGE: ** 21 | build_flags: -dev -verbose # Load in -dev mode to get unit test code preloaded 22 | test_package: UnitTest 23 | 24 | # ** FOR GENERAL USE, SHOULD NOT NEED TO CHANGE: ** 25 | instance: iris 26 | # Note: test_reports value is duplicated in test_flags environment variable 27 | test_reports: test-reports 28 | test_flags: >- 29 | -verbose -DUnitTest.ManagerClass=TestCoverage.Manager -DUnitTest.JUnitOutput=/test-reports/junit.xml 30 | -DUnitTest.FailuresAreFatal=1 -DUnitTest.Manager=TestCoverage.Manager 31 | -DUnitTest.UserParam.CoverageReportClass=TestCoverage.Report.Cobertura.ReportGenerator 32 | -DUnitTest.UserParam.CoverageReportFile=/source/coverage.xml 33 | 34 | # Steps represent a sequence of tasks that will be executed as part of the job 35 | steps: 36 | 37 | # Checks out this repository under $GITHUB_WORKSPACE, so your job can access it 38 | - uses: actions/checkout@v2 39 | 40 | # Also need to check out timleavitt/forgery until the official version installable via ZPM 41 | - uses: actions/checkout@v2 42 | with: 43 | repository: timleavitt/forgery 44 | path: forgery 45 | 46 | - name: Run Container 47 | run: | 48 | # Create test_reports directory to share test results before running container 49 | mkdir $test_reports 50 | chmod 777 $test_reports 51 | # Run InterSystems IRIS instance 52 | docker pull $container_image 53 | docker run -d -h $instance --name $instance -v $GITHUB_WORKSPACE:/source -v $GITHUB_WORKSPACE/$test_reports:/$test_reports --init $container_image 54 | echo halt > wait 55 | # Wait for instance to be ready 56 | until docker exec --interactive $instance iris session $instance < wait; do sleep 1; done 57 | 58 | - name: Install TestCoverage 59 | run: | 60 | echo "zpm \"install testcoverage\":1:1" > install-testcoverage 61 | docker exec --interactive $instance iris session $instance -B < install-testcoverage 62 | # Workaround for permissions issues in TestCoverage (creating directory for source export) 63 | chmod 777 $GITHUB_WORKSPACE 64 | 65 | - name: Install Forgery 66 | run: | 67 | echo "zpm \"load /source/forgery\":1:1" > load-forgery 68 | docker exec --interactive $instance iris session $instance -B < load-forgery 69 | 70 | # Runs a set of commands using the runners shell 71 | - name: Build and Test 72 | run: | 73 | # Run build 74 | echo "zpm \"load /source $build_flags\":1:1" > build 75 | # Test package is compiled first as a workaround for some dependency issues. 76 | echo "do \$System.OBJ.CompilePackage(\"$test_package\",\"ckd\") " > test 77 | # Run tests 78 | echo "zpm \"$package test -only $test_flags\":1:1" >> test 79 | docker exec --interactive $instance iris session $instance -B < build && docker exec --interactive $instance iris session $instance -B < test && bash <(curl -s https://codecov.io/bash) 80 | 81 | # Generate and Upload HTML xUnit report 82 | - name: XUnit Viewer 83 | id: xunit-viewer 84 | uses: AutoModality/action-xunit-viewer@v1 85 | if: always() 86 | with: 87 | # With -DUnitTest.FailuresAreFatal=1 a failed unit test will fail the build before this point. 88 | # This action would otherwise misinterpret our xUnit style output and fail the build even if 89 | # all tests passed. 90 | fail: false 91 | - name: Attach the report 92 | uses: actions/upload-artifact@v1 93 | if: always() 94 | with: 95 | name: ${{ steps.xunit-viewer.outputs.report-name }} 96 | path: ${{ steps.xunit-viewer.outputs.report-dir }} 97 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/Action/Generator.cls: -------------------------------------------------------------------------------- 1 | /// This class is internal to AppS.REST; consumers should not use or reference it directly. 2 | Class AppS.REST.Model.Action.Generator [ System = 2 ] 3 | { 4 | 5 | ClassMethod GenerateClassActions(pCode As %Stream.Object, pActionClassName As %Dictionary.CacheClassname) 6 | { 7 | Do ..GenerateActions(pCode, pActionClassName, "class") 8 | } 9 | 10 | ClassMethod GenerateInstanceActions(pCode As %Stream.Object, pActionClassName As %Dictionary.CacheClassname) 11 | { 12 | Do ..GenerateActions(pCode, pActionClassName, "instance") 13 | } 14 | 15 | ClassMethod GenerateActions(pCode, pActionClassName, pType) [ Private ] 16 | { 17 | #dim metadata As AppS.REST.Model.Action.t.actions 18 | #dim action As AppS.REST.Model.Action.t.action 19 | 20 | Set initTLevel = $TLevel 21 | 22 | Try { 23 | 24 | Set sourceClass = ..GetSourceClass(pActionClassName) 25 | If (sourceClass = "") { 26 | Quit 27 | } 28 | 29 | Set metadata = ..GetActionMetadata(sourceClass) 30 | Set resourceName = $Parameter(sourceClass,"RESOURCENAME") 31 | 32 | TSTART 33 | For index=1:1:metadata.actions.Count() { 34 | Set action = metadata.actions.GetAt(index) 35 | If (action.target '= pType) { 36 | Continue 37 | } 38 | Do action.Generate(sourceClass, .actionCode, .accepts, .contentType) 39 | 40 | // Register newly-created actions. 41 | &sql(insert or update into AppS_REST.ActionMap 42 | (ResourceName, ActionName, ActionTarget, HTTPVerb, MediaType, Accepts, ModelClass, ImplementationClass) values 43 | (:resourceName, :action.name, :action.target, :action.method, :contentType, :accepts, :sourceClass, :pActionClassName)) 44 | If (SQLCODE < 0) { 45 | Throw ##class(%Exception.SQL).CreateFromSQLCODE(SQLCODE,%msg) 46 | } 47 | 48 | Merge generateMap($ListBuild(action.name,action.method)) = actionCode 49 | } 50 | 51 | Do %code.WriteLine($c(9)_"Set tAction = $ZConvert(pAction,""L"")") 52 | 53 | Set key = "" 54 | For { 55 | Set key = $Order(generateMap(key),1,lineCount) 56 | Quit:key="" 57 | 58 | Set $ListBuild(actionName,httpMethod) = key 59 | 60 | If $Increment(counter) = 1 { 61 | Set prefix = $c(9)_"If " 62 | } Else { 63 | Set prefix = $c(9)_"} ElseIf " 64 | } 65 | Set prefix = prefix _ "(tAction = "_$$$QUOTE($ZConvert(actionName,"L"))_") && (pHTTPMethod = "_$$$QUOTE(httpMethod)_") {" 66 | 67 | Do %code.WriteLine(prefix) 68 | 69 | For line=1:1:lineCount { 70 | Do %code.WriteLine($c(9)_generateMap(key,line)) 71 | } 72 | } 73 | 74 | If $Get(counter,0) { 75 | Do %code.WriteLine($c(9)_"}") 76 | } 77 | 78 | TCOMMIT 79 | } Catch e { 80 | } // Finally: 81 | 82 | While $TLevel > initTLevel { 83 | TROLLBACK 1 84 | } 85 | 86 | If $IsObject($Get(e)) { 87 | Throw e 88 | } 89 | } 90 | 91 | ClassMethod GetActionMetadata(pSourceClass As %Dictionary.CacheClassname) As AppS.REST.Model.Action.t.actions 92 | { 93 | Set emptyActions = ##class(AppS.REST.Model.Action.t.actions).%New() 94 | If (pSourceClass = "") || $$$comClassKeyGet(pSourceClass,$$$cCLASSabstract) { 95 | Return emptyActions 96 | } 97 | 98 | Set origin = $$$comMemberKeyGet(pSourceClass,$$$cCLASSxdata,"ActionMap",$$$cXDATAorigin) 99 | Set xData = ##class(%Dictionary.XDataDefinition).IDKEYOpen(origin,"ActionMap",,.sc) 100 | $$$ThrowOnError(sc) 101 | 102 | If (xData.Data.Size = 0) { 103 | Return emptyActions 104 | } 105 | 106 | Set reader = ##class(%XML.Reader).%New() 107 | Do reader.CorrelateRoot("AppS.REST.Model.Action.t.actions") 108 | $$$ThrowOnError(reader.OpenStream(xData.Data)) 109 | While reader.Next(.actions,.sc) { 110 | $$$ThrowOnError(sc) 111 | $$$ThrowOnError(actions.%ValidateObject()) 112 | Return actions 113 | } 114 | $$$ThrowOnError(sc) 115 | 116 | Return emptyActions 117 | } 118 | 119 | ClassMethod GetSourceClass(pActionClassName As %Dictionary.CacheClassname) As %Dictionary.CacheClassname 120 | { 121 | If $$$comClassKeyGet(pActionClassName,$$$cCLASSabstract) { 122 | Quit "" 123 | } 124 | 125 | Set sourceClass = $$$comMemberKeyGet(pActionClassName,$$$cCLASSparameter,"SOURCECLASS",$$$cPARAMdefault) 126 | If (sourceClass = "") { 127 | Set sc = $$$ERROR($$$GeneralError,$$$FormatText("SOURCELCASS parameter not defined in class %1",pActionClassName)) 128 | $$$ThrowStatus(sc) 129 | } 130 | Quit sourceClass 131 | } 132 | 133 | ClassMethod GetClassDependencies(pSourceClass As %Dictionary.CacheClassname) As %List 134 | { 135 | #dim metadata As AppS.REST.Model.Action.t.actions 136 | #dim action As AppS.REST.Model.Action.t.action 137 | 138 | Set metadata = ..GetActionMetadata(pSourceClass) 139 | Set list = "" 140 | For index=1:1:metadata.actions.Count() { 141 | Set action = metadata.actions.GetAt(index) 142 | Do action.GetDependencies(pSourceClass,.dependencies) 143 | } 144 | 145 | Set class = "" 146 | For { 147 | Set class = $Order(dependencies(class)) 148 | Quit:class="" 149 | 150 | Set list = list_$ListBuild(class) 151 | } 152 | 153 | Quit list 154 | } 155 | 156 | } 157 | 158 | -------------------------------------------------------------------------------- /cls/AppS/REST/QueryGenerator.cls: -------------------------------------------------------------------------------- 1 | /// This class is internal to AppS.REST; consumers should not use or reference it directly. 2 | Class AppS.REST.QueryGenerator [ System = 3 ] 3 | { 4 | 5 | ClassMethod GetQuery(className As %Dictionary.CacheClassname, columns As %DynamicObject, index As %String, ByRef URLParams, Output queryParams) As %String 6 | { 7 | Set schemaName = $$$comClassKeyGet(className,$$$cCLASSsqlschemaname) 8 | Set tableName = $$$comClassKeyGet(className,$$$cCLASSsqltablename) 9 | If $System.SQL.IsReservedWord(tableName) { 10 | Set tableName = $$$QUOTE(tableName) 11 | } 12 | If (index = "ID") { 13 | // Allow SqlRowIdName to override default of "ID" 14 | Set columnName = $$$comClassKeyGet(className,$$$cCLASSsqlrowidname) 15 | If (columnName '= "") { 16 | Set index = columnName 17 | } 18 | } 19 | Set query = ..BuildQuery(schemaName _ "." _ tableName, columns, index, .URLParams, .queryParams) 20 | Return query 21 | } 22 | 23 | /// Takes the tableName and the params from the url. 24 | /// Returns the text of a query selecting ID from the table according to the filters specified by params. 25 | /// queryParams is an output of the array which needs to be passed to %Execute when running the query, 26 | /// since the where clause of this query will be assembled with terms like "property > ?" 27 | ClassMethod BuildQuery(tableName As %String, columns As %DynamicObject, index As %String, ByRef params, Output queryParams, pIDQuery As %Boolean = 0) 28 | { 29 | // creates a query that looks something like: 30 | // select ID from sample.person where 1=1 and age>? 31 | 32 | // If we're given an index other than ID to use, we need to select that instead of ID (we could do it in addition to ID) 33 | Set indexColumn = "ID" 34 | If $Data(index, indexColumn) {} 35 | If (indexColumn '= "ID") { 36 | Set indexColumn = indexColumn _ " as ID" 37 | } 38 | Set query = "select " _ indexColumn _ " from " _ tableName _ " where 1=1" 39 | 40 | // loop over the params to assemble the where clause and corresponding params array 41 | Kill queryParams 42 | Set orderBy = "" 43 | Set ascOrDesc = "asc" 44 | Set propName = $Order(params("")) 45 | While (propName '= "") { 46 | Set param = params(propName, 1) 47 | // if the property is $orderBy, then we store the order by string 48 | // for appending at the end of the where clause building 49 | If (propName = "$orderBy") { 50 | Set orderBy = param 51 | Set propName = $Order(params(propName)) 52 | Continue 53 | } 54 | // the queryParams output gets assembled with what we need to pass to %Execute 55 | Set query = query _ " and " _ ..GetWhereSegment(propName, param, .queryParams, columns) 56 | Set propName = $Order(params(propName)) 57 | } 58 | 59 | // append the order by clause if it exists 60 | If (orderBy '= "") { 61 | If ($Extract(orderBy, 1) = "-") { 62 | Set ascOrDesc = "desc" 63 | Set orderBy = $Extract(orderBy, 2, *) 64 | } 65 | Set orderColName = columns.%Get(orderBy) 66 | If (orderColName = "") { 67 | Throw ##class(AppS.REST.Exception.InvalidColumnException).New(orderBy) 68 | } 69 | Set query = query _ " order by " _ orderColName _ " " _ ascOrDesc 70 | } 71 | 72 | Return query 73 | } 74 | 75 | /// examples: 76 | /// age[lte]=50 77 | /// age[gte]=20 78 | /// age[eq]=30 79 | /// age[noteq]=30 80 | /// age[notgeq]=40 81 | /// age[isnull] 82 | /// etc. 83 | /// can prepend "not" onto these comparators: 84 | /// e.g. notlte, noteq 85 | ClassMethod GetWhereSegment(paramBeforeEquals As %String, paramAfterEquals As %String, ByRef queryParams, legalColumns As %DynamicObject) [ Private ] 86 | { 87 | //map of the text of the comparator in the URL to its sql symbol 88 | Set comparatorMap = { 89 | "lte": " < ", 90 | "gte": " > ", 91 | "eq": " = ", 92 | "leq": " <= ", 93 | "geq": " >= ", 94 | "stwith": " %startswith ", 95 | "isnull": " is null" 96 | } 97 | 98 | If '..ValidateSyntax(paramBeforeEquals, paramAfterEquals, comparatorMap) { 99 | Throw ##class(AppS.REST.Exception.ParameterParsingException).New(paramBeforeEquals_"="_paramAfterEquals) 100 | } 101 | 102 | Set paramName = $Piece(paramBeforeEquals, "[", 1) 103 | 104 | // check if this column is available for filtering 105 | // if you set a displayname to a column name you'll be able to search on it probably 106 | Set columnName = legalColumns.%Get(paramName) 107 | 108 | If (columnName = "") { 109 | Throw ##class(AppS.REST.Exception.InvalidColumnException).New(paramName) 110 | } 111 | 112 | Set comparatorCode = $piece($piece(paramBeforeEquals, "[", 2), "]", 1) 113 | Set paramValue = paramAfterEquals 114 | 115 | // see if the comparator starts with "not", and If it does, we'll start the clause with a "not" 116 | Set not = "" 117 | If ($Extract(comparatorCode, 1, 3) = "not") { 118 | Set not = "not " 119 | Set comparatorCode = $Extract(comparatorCode, 4, *) 120 | } 121 | 122 | Set comparator = comparatorMap.%Get(comparatorCode) 123 | 124 | If (comparatorCode [ "isnull") { 125 | Return not _ columnName _ comparator 126 | } 127 | 128 | // put the value of this param in a subscripted array which will eventually be passed to %Execute 129 | Set queryParams($Increment(queryParams)) = paramValue 130 | Return not _ columnName _ comparator _ "?" 131 | } 132 | 133 | ClassMethod ValidateSyntax(paramBeforeEquals, paramAfterEquals, comparatorMap) [ Private ] 134 | { 135 | // loop over the comparators we have and create an or'ed regex segement with them 136 | Set legalOpString = "(" 137 | Set iter = comparatorMap.%GetIterator() 138 | While iter.%GetNext(.operatorCode) { 139 | Set legalOpString = legalOpString _ operatorCode _ "|" 140 | } 141 | Set legalOpString = $extract(legalOpString, 1, *-1)_")" 142 | 143 | // regex for acceptable formats for URL query parameters 144 | // e.g.: "[^\[\]]+\[(not)?(stwith|lte|gte|eq|leq|geq)\]" 145 | Set correctFormat = "[^\[\]]+\[(not)?" _ legalOpString _ "\]" 146 | Return $match(paramBeforeEquals, correctFormat) 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/Sample/Data/Vendor.cls: -------------------------------------------------------------------------------- 1 | /// Use or operation of this code is subject to acceptance of the license available in the code repository for this code. 2 | /// The Vendor class is a persistent class 3 | /// containing vendor information.
4 | /// This class demonstrates how to use the %CacheSQLStorage storage 5 | /// class to provide custom storage for a persistent class. Typically the 6 | /// %CacheSQLStorage storage class is used to provide object access 7 | /// to previously existing storage structures. 8 | Class UnitTest.AppS.REST.Sample.Data.Vendor Extends (%Persistent, %Populate, %XML.Adaptor, AppS.REST.Model.Adaptor) [ SqlRowIdName = Vendor, StorageStrategy = SQLStorage ] 9 | { 10 | 11 | /// REST resource name 12 | Parameter RESOURCENAME = "vendor"; 13 | 14 | /// REST permissions: admins can create/delete/edit, anyone can query/read. 15 | ClassMethod CheckPermission(pID As %String, pOperation As %String, pUserContext As UnitTest.AppS.REST.Sample.UserContext) As %Boolean 16 | { 17 | If pUserContext.IsAdmin { 18 | Quit 1 19 | } Else { 20 | Quit ((pOperation = "QUERY") || (pOperation = "READ")) 21 | } 22 | } 23 | 24 | /// Name Index 25 | Index IndexNName On Name; 26 | 27 | /// Vendor name. 28 | Property Name As %String(POPSPEC = "Company()"); 29 | 30 | /// Vendor address. 31 | Property Address As UnitTest.AppS.REST.Sample.Data.Address(POPSPEC = "##class(Address).PopulateSerial()"); 32 | 33 | /// Name of primary vendor contact. 34 | Property Contact As %String(POPSPEC = "Name()"); 35 | 36 | /// Discount rate. 37 | Property DiscRate As %Float(MAXVAL = 100, MINVAL = 0); 38 | 39 | /// Discount days. 40 | Property DiscDays As %Integer(MAXVAL = 999, MINVAL = 0); 41 | 42 | /// Net days. 43 | Property NetDays As %Integer(MAXVAL = 999, MINVAL = 0); 44 | 45 | /// Days clear. 46 | Property DaysClear As %Integer(MAXVAL = 999, MINVAL = 0); 47 | 48 | /// Payment Flag 49 | Property PayFlag As %String(DISPLAYLIST = ",Never,Minimum", VALUELIST = ",N,M"); 50 | 51 | /// Minimum Payment. 52 | Property MinPayment As %Float(MAXVAL = 999999, MINVAL = 0); 53 | 54 | /// Last Invoice Date. 55 | Property LastInvDate As %Date(MAXVAL = "", MINVAL = ""); 56 | 57 | /// Last Payment Date. 58 | Property LastPayDate As %Date(MAXVAL = "", MINVAL = ""); 59 | 60 | /// Balance. 61 | Property Balance As %Float(MAXVAL = 999999999, MINVAL = -999999999) [ InitialExpression = 0 ]; 62 | 63 | /// Vendor tax reporting status. 64 | Property TaxReporting As %String(DISPLAYLIST = ",Exempt,Required", VALUELIST = ",E,"); 65 | 66 | Storage SQLStorage 67 | { 68 | 200 69 | 70 | 2.46 71 | 1 72 | 73 | 74 | 36.42,City:7.26,State:2,Street:16.75,Zip:5 75 | 0.5000%,City:3.8462%,State:2.0000%,Street:0.5000%,Zip:0.5000% 76 | 77 | 78 | 9.37 79 | 0.5000% 80 | 81 | 82 | 15.99 83 | 0.5000% 84 | 85 | 86 | 2.88 87 | 0.5495% 88 | 89 | 90 | 2.93 91 | 0.5464% 92 | 93 | 94 | 1.92 95 | 1.0753% 96 | 97 | 98 | 5 99 | 0.5000% 100 | 101 | 102 | 5 103 | 0.5025% 104 | 105 | 106 | 5.92 107 | 0.5000% 108 | 109 | 110 | 17.22 111 | 0.5076% 112 | 113 | 114 | 2.86 115 | 0.5618% 116 | 117 | 118 | 1 119 | 50.0000% 120 | 121 | 122 | .54 123 | 50.0000% 124 | 125 | $i(^Sample.VendorD) 126 | 127 | -16 128 | 129 | 2 130 | 131 | 132 | 1 133 | 3 134 | 135 | 136 | 3 137 | 138 | 139 | 7 140 | 141 | 142 | 5 143 | 144 | 145 | 4 146 | 147 | 148 | 1 149 | 1 150 | 151 | 152 | 1 153 | 2 154 | 155 | 156 | 9 157 | 158 | 159 | 1 160 | 161 | 162 | 6 163 | 164 | 165 | 8 166 | 167 | 168 | 10 169 | 170 | ^Sample.DataD 171 | list 172 | 173 | {Vendor} 174 | 175 | data 176 | 177 | 178 | -4 179 | ^Sample.VendorI 180 | list 181 | 182 | "N" 183 | 184 | 185 | $$SQLUPPER({Name}) 186 | 187 | 188 | {Vendor} 189 | 190 | index 191 | 192 | ^Sample.VendorS 193 | %Storage.SQL 194 | } 195 | 196 | } 197 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/Util/Buffer.cls: -------------------------------------------------------------------------------- 1 | Include %callout 2 | 3 | /// Note: assertions are generally made *after* tests involving output capture to avoid unit test assertions being written to the string buffer. 4 | Class UnitTest.AppS.Util.Buffer Extends %UnitTest.TestCase 5 | { 6 | 7 | Method TestReadToString() 8 | { 9 | Set tStringBuffer = ##class(AppS.Util.Buffer).%New() 10 | Do $$$AssertStatusOK(tStringBuffer.ReadToString(.tEmptyString)) 11 | Do $$$AssertEquals(tEmptyString, "") 12 | Set tSC1 = tStringBuffer.BeginCaptureOutput() 13 | Write "This is a test string." 14 | Set tSC2 = tStringBuffer.EndCaptureOutput(.tOutput) 15 | 16 | Do $$$AssertStatusOK(tSC1) 17 | Do $$$AssertStatusOK(tSC2) 18 | Do $$$AssertEquals(tOutput,"This is a test string.") 19 | } 20 | 21 | Method TestReadToStringException() 22 | { 23 | // Error case: exception thrown 24 | Set tStringBuffer = ##class(AppS.Util.Buffer).%New() 25 | Set tSC1 = tStringBuffer.BeginCaptureOutput() 26 | Do ..UtilCloseStringBufferDevice(tStringBuffer) 27 | Set tSC2 = tStringBuffer.EndCaptureOutput(.tOutput) 28 | Do $$$AssertStatusOK(tSC1) 29 | Do $$$AssertStatusNotOK(tSC2) 30 | Do $$$AssertTrue($System.Status.Equals(tSC2,$$$ERRORCODE($$$CacheError))) 31 | } 32 | 33 | Method TestReadToStream() 34 | { 35 | // Normal cases: temp binary stream, file character stream 36 | For tOutput = ##class(%Stream.TmpBinary).%New(),##class(%Stream.FileCharacter).%New() { 37 | Set tStringBuffer = ##class(AppS.Util.Buffer).%New() 38 | Do $$$AssertStatusOK(tStringBuffer.ReadToStream(.tEmptyStream)) 39 | Do $$$AssertEquals(tEmptyStream.Read(), "") 40 | Set tSC1 = tStringBuffer.BeginCaptureOutput() 41 | Write "This is a test string." 42 | Set tSC2 = tStringBuffer.EndCaptureOutput(.tOutput) 43 | 44 | Do $$$AssertStatusOK(tSC1) 45 | Do $$$AssertStatusOK(tSC2) 46 | Do $$$AssertEquals(tOutput.Read(),"This is a test string.") 47 | } 48 | } 49 | 50 | Method TestReadToStreamWriteFails() 51 | { 52 | // Error case: file can't be written to (we'll delete it) 53 | Set tOutput = ##class(%Stream.FileCharacter).%New() 54 | Do tOutput.Write("") 55 | Set tStringBuffer = ##class(AppS.Util.Buffer).%New() 56 | Set tOutput.%Location = tOutput.Filename 57 | If ##class(%Library.File).Exists(tOutput.%Location) { 58 | Do $$$AssertTrue(##class(%Library.File).Delete(tOutput.%Location)) 59 | } 60 | Do $$$AssertTrue(##class(%Library.File).CreateDirectory(tOutput.%Location)) 61 | Set tOutput.Filename = ##class(%Library.File).NormalizeFilename("foo.txt",tOutput.%Location) 62 | Do $$$AssertTrue(##class(%Library.File).RemoveDirectory(tOutput.%Location)) 63 | Set tSC1 = tStringBuffer.BeginCaptureOutput() 64 | Set tSC2 = tStringBuffer.ReadToStream(.tOutput) 65 | Set tSC3 = tStringBuffer.EndCaptureOutput() 66 | Do $$$AssertStatusOK(tSC1) 67 | Do $$$AssertStatusOK(tSC3) 68 | Do $$$AssertStatusNotOK(tSC2) 69 | Do $$$AssertTrue($System.Status.Equals(tSC2,$$$ERRORCODE($$$FileCanNotOpen))) 70 | } 71 | 72 | Method TestReadToStreamException() 73 | { 74 | // Error case: exception thrown 75 | Set tOutput = ##class(%Stream.FileCharacter).%New() 76 | Set tStringBuffer = ##class(AppS.Util.Buffer).%New() 77 | Set tSC1 = tStringBuffer.BeginCaptureOutput() 78 | Do ..UtilCloseStringBufferDevice(tStringBuffer) 79 | Set tSC2 = tStringBuffer.EndCaptureOutput(.tOutput) 80 | Do $$$AssertStatusOK(tSC1) 81 | Do $$$AssertStatusNotOK(tSC2) 82 | Do $$$AssertTrue($System.Status.Equals(tSC2,$$$ERRORCODE($$$CacheError))) 83 | } 84 | 85 | Method TestBeginCaptureOutputBadTranslateTable() 86 | { 87 | Set tStringBuffer = ##class(AppS.Util.Buffer).%New() 88 | Set tStringBuffer.TranslateTable = "SomethingBad" 89 | Set tSC = tStringBuffer.BeginCaptureOutput() 90 | Do $$$AssertStatusNotOK(tSC) 91 | Do $$$AssertTrue($System.Status.Equals(tSC,$$$ERRORCODE($$$CacheError))) 92 | } 93 | 94 | Method TestEndCaptureOutputException() 95 | { 96 | // TODO: Fix for IRIS 97 | /* 98 | Set tFile = ##class(%Library.File).TempFilename() 99 | Open tFile:"WNS" 100 | Use tFile 101 | Set tStringBuffer = ##class(AppS.Util.Buffer).%New() 102 | Set tSC1 = tStringBuffer.BeginCaptureOutput() 103 | Close tFile:"D" 104 | Set tSC2 = tStringBuffer.EndCaptureOutput() 105 | Do $$$AssertStatusOK(tSC1) 106 | Do $$$AssertStatusNotOK(tSC2) 107 | Do $$$AssertTrue($System.Status.Equals(tSC2,$$$ERRORCODE($$$CacheError))) 108 | */ 109 | } 110 | 111 | Method TestOnClose() 112 | { 113 | Set tSC = $$$OK 114 | Try { 115 | Set tOldDevice = $io 116 | Set tStringBuffer = ##class(AppS.Util.Buffer).%New() 117 | Set tSC1 = tStringBuffer.BeginCaptureOutput() 118 | Kill tStringBuffer 119 | Set tAfterDevice = $io 120 | Do $$$AssertStatusOK(tSC1) 121 | Do $$$AssertEquals(tAfterDevice,tOldDevice) 122 | } Catch e { 123 | Set tSC = e.AsStatus() 124 | } 125 | Do $$$AssertStatusOK(tSC,"No exceptions occurred.") 126 | } 127 | 128 | Method TestChangeCarriageReturnMode() 129 | { 130 | Set tStringBuffer = ##class(AppS.Util.Buffer).%New() 131 | Do $$$AssertEquals(tStringBuffer.CarriageReturnMode,1) 132 | Set tSC1 = tStringBuffer.BeginCaptureOutput() 133 | Set tStringBuffer.CarriageReturnMode = 0 134 | Set tSC2 = tStringBuffer.EndCaptureOutput() 135 | Do $$$AssertEquals(tStringBuffer.CarriageReturnMode,0) 136 | Do $$$AssertStatusOK(tSC1) 137 | Do $$$AssertStatusOK(tSC2) 138 | } 139 | 140 | Method TestChangeCarriageReturnModeException() 141 | { 142 | Set tStringBuffer = ##class(AppS.Util.Buffer).%New() 143 | Do $$$AssertEquals(tStringBuffer.CarriageReturnMode,1) 144 | Set tSC1 = tStringBuffer.BeginCaptureOutput() 145 | Do ..UtilCloseStringBufferDevice(tStringBuffer) 146 | Set tStringBuffer.CarriageReturnMode = 0 147 | Do $$$AssertStatusOK(tSC1) 148 | Do $$$AssertEquals(tStringBuffer.CarriageReturnMode,1) 149 | } 150 | 151 | Method TestChangeTranslateTable() 152 | { 153 | Set tStringBuffer = ##class(AppS.Util.Buffer).%New() 154 | Do $$$AssertEquals(tStringBuffer.TranslateTable,"UTF8") 155 | Set tSC1 = tStringBuffer.BeginCaptureOutput() 156 | Set tStringBuffer.TranslateTable = "RAW" 157 | Set tSC2 = tStringBuffer.EndCaptureOutput() 158 | Do $$$AssertEquals(tStringBuffer.TranslateTable,"RAW") 159 | Do $$$AssertStatusOK(tSC1) 160 | Do $$$AssertStatusOK(tSC2) 161 | } 162 | 163 | Method TestChangeTranslateTableInvalid() 164 | { 165 | Set tStringBuffer = ##class(AppS.Util.Buffer).%New() 166 | Do $$$AssertEquals(tStringBuffer.TranslateTable,"UTF8") 167 | Set tSC1 = tStringBuffer.BeginCaptureOutput() 168 | Set tStringBuffer.TranslateTable = "SomethingBad" 169 | Set tSC2 = tStringBuffer.EndCaptureOutput() 170 | Do $$$AssertEquals(tStringBuffer.TranslateTable,"UTF8") 171 | Do $$$AssertStatusOK(tSC1) 172 | Do $$$AssertStatusOK(tSC2) 173 | } 174 | 175 | /// Called to close the device the string buffer is trying to use. 176 | /// This is useful to trigger various error conditions 177 | Method UtilCloseStringBufferDevice(pStringBuffer As AppS.Util.Buffer) 178 | { 179 | // This should match the device name set in AppS.Util.Buffer:%OnNew 180 | Close "|XDEV|"_(+pStringBuffer) 181 | } 182 | 183 | } 184 | -------------------------------------------------------------------------------- /cls/AppS/Util/Buffer.cls: -------------------------------------------------------------------------------- 1 | Include %callout 2 | 3 | /// Encapsulates the CacheXSLT String Buffer XDEV device.
4 | /// Note that only one AppS.Util.Buffer instance may be used at a time per job. 5 | Class AppS.Util.Buffer Extends %RegisteredObject 6 | { 7 | 8 | /// Size of the XSLT string buffer, in MB.
9 | /// Changes will only take effect the next time BeginCaptureOutput() is called.
10 | Property StringBufferSize As %Integer [ InitialExpression = 25 ]; 11 | 12 | /// Corresponds to "C" mode flag in device open.
13 | /// May be changed after BeginCaptureOutput() is called and the XDEV device is open.
14 | Property CarriageReturnMode As %Boolean [ InitialExpression = 1 ]; 15 | 16 | Method CarriageReturnModeSet(value As %Boolean) As %Status 17 | { 18 | Set tSC = $$$OK 19 | Set tIO = $io 20 | Try { 21 | Set tValue = ''value 22 | If ..DeviceOpen { 23 | Set tModeChange = $Select(tValue: "+", 1: "-")_"C" 24 | Use ..Device:(::tModeChange) 25 | } 26 | } Catch e { 27 | Set tSC = e.AsStatus() 28 | } 29 | Use tIO 30 | If $$$ISOK(tSC) { 31 | Set i%CarriageReturnMode = tValue 32 | } 33 | Quit tSC 34 | } 35 | 36 | /// Translate Table to use - by default, UTF8 for unicode instances, RAW otherwise.
37 | /// May be changed after BeginCaptureOutput() is called and the XDEV device is open.
38 | Property TranslateTable As %String [ InitialExpression = {$select($$$IsUnicode:"UTF8",1:"RAW")} ]; 39 | 40 | Method TranslateTableSet(value As %String) As %Status 41 | { 42 | Set tSC = $$$OK 43 | Set tIO = $io 44 | Try { 45 | // TODO: validate that value is a valid TranslateTable, especially when ..DeviceOpen = 0 46 | If ..DeviceOpen { 47 | Use ..Device:(/IOT=value) 48 | } 49 | } Catch e { 50 | Set tSC = e.AsStatus() 51 | } 52 | Use tIO 53 | If $$$ISOK(tSC) { 54 | Set i%TranslateTable = value 55 | } 56 | Quit tSC 57 | } 58 | 59 | /// Input buffer size, in bytes.
60 | /// Changes will only take effect the next time BeginCaptureOutput() is called.
61 | Property InputBufferSize As %Integer(MAXVAL = 1048576, MINVAL = 1024) [ InitialExpression = 16384 ]; 62 | 63 | /// Output buffer size, in bytes.
64 | /// Changes will only take effect the next time BeginCaptureOutput() is called.
65 | Property OutputBufferSize As %Integer(MAXVAL = 1048576, MINVAL = 1024) [ InitialExpression = 16384 ]; 66 | 67 | /// Name of XDEV device. 68 | Property Device As %String [ Internal, Private ]; 69 | 70 | /// Tracks whether Device is currently open. 71 | Property DeviceOpen As %Boolean [ InitialExpression = 0, Internal, Private ]; 72 | 73 | /// Keeps track of the previously opened device. 74 | Property PreviousDevice As %String [ Internal, Private ]; 75 | 76 | /// Keeps track of the previous state of the I/O redirection flag 77 | Property PreviousIORedirectFlag As %Boolean [ Internal, Private ]; 78 | 79 | /// Keeps track of the previous mnemonic routine 80 | Property PreviousMnemonicRoutine As %String [ Internal, Private ]; 81 | 82 | /// Initializes the device name for this object. 83 | Method %OnNew() As %Status [ Private, ServerOnly = 1 ] 84 | { 85 | // Multiple processes can use the same XDEV device name without conflict, so we can use the object reference to identify the device. 86 | Set ..Device = "|XDEV|"_(+$This) 87 | Quit $$$OK 88 | } 89 | 90 | /// Begins capturing output.
91 | Method BeginCaptureOutput() As %Status 92 | { 93 | Set tSC = $$$OK 94 | Try { 95 | Set tModeParams = $Select(..CarriageReturnMode: "C", 1: "")_"S" 96 | Close ..Device 97 | Open ..Device:($ZF(-6,$$$XSLTLibrary,12):..StringBufferSize:tModeParams:/HOSTNAME="XSLT":/IOT=..TranslateTable:/IBU=..InputBufferSize:/OBU=..OutputBufferSize) 98 | Set ..DeviceOpen = 1 99 | Set ..PreviousDevice = $io 100 | Set ..PreviousIORedirectFlag = ##class(%Library.Device).ReDirectIO() 101 | Set ..PreviousMnemonicRoutine = ##class(%Library.Device).GetMnemonicRoutine() 102 | Use ..Device 103 | If ..PreviousIORedirectFlag { 104 | Do ##class(%Library.Device).ReDirectIO(0) 105 | } 106 | } Catch e { 107 | Set tSC = e.AsStatus() 108 | Close ..Device 109 | Set ..DeviceOpen = 0 110 | } 111 | Quit tSC 112 | } 113 | 114 | /// Reads all output from the buffer to stream pStream, 115 | /// which will be initialized as a %Stream.TmpBinary object if not provided. 116 | Method ReadToStream(ByRef pStream As %Stream.Object) As %Status 117 | { 118 | Set tSC = $$$OK 119 | Set tOldIO = $io 120 | Try { 121 | If '$IsObject($Get(pStream)) { 122 | Set pStream = ##class(%Stream.TmpBinary).%New() 123 | } 124 | 125 | If '..DeviceOpen { 126 | // No-op 127 | Quit 128 | } 129 | 130 | Use ..Device 131 | 132 | // Flush any remaining output 133 | Write *-3 134 | 135 | // Stream 136 | If pStream.%IsA("%Stream.FileCharacter") { 137 | // Force stream's file to open 138 | Set tSC = pStream.Write("") 139 | If $$$ISERR(tSC) { 140 | Quit 141 | } 142 | 143 | Set tFile = pStream.Filename 144 | For { 145 | Use ..Device 146 | Set tChunk = "" 147 | Try { 148 | Read tChunk:0 149 | } Catch {} 150 | If '$Length(tChunk) { 151 | Quit 152 | } 153 | Use tFile 154 | Write tChunk 155 | } 156 | } 157 | Else { 158 | For { 159 | Use ..Device 160 | Set tChunk = "" 161 | Try { 162 | Read tChunk:0 163 | } Catch {} 164 | If '$Length(tChunk) { 165 | Quit 166 | } 167 | Do pStream.Write(tChunk) 168 | } 169 | } 170 | } Catch e { 171 | If e.Name="" { 172 | Set tSC = $$$OK 173 | } Else { 174 | Set tSC = e.AsStatus() 175 | } 176 | } 177 | Use tOldIO 178 | Quit tSC 179 | } 180 | 181 | /// Reads all output from the buffer to pString. 182 | Method ReadToString(Output pString As %String) As %Status 183 | { 184 | Set tSC = $$$OK 185 | Set tOldIO = $io 186 | Set pString = $Get(pString) 187 | Try { 188 | If '..DeviceOpen { 189 | // No-op 190 | Quit 191 | } 192 | 193 | Use ..Device 194 | 195 | // Flush any remaining output 196 | Write *-3 197 | 198 | // String 199 | For { 200 | Use ..Device 201 | Set tChunk = "" 202 | Try { 203 | Read tChunk:0 204 | } Catch {} 205 | If '$Length(tChunk) { 206 | Quit 207 | } 208 | Set pString = pString _ tChunk 209 | } 210 | } Catch e { 211 | If e.Name="" { 212 | Set tSC = $$$OK 213 | } Else { 214 | Set tSC = e.AsStatus() 215 | } 216 | } 217 | Use tOldIO 218 | Quit tSC 219 | } 220 | 221 | /// Ends capturing output
222 | /// If pOutput is any sort of stream, output will be written to it.
223 | /// Otherwise, it will be returned as a string (initialized to "" before retrieving output from the buffer).
224 | Method EndCaptureOutput(ByRef pOutput) As %Status 225 | { 226 | Set tSC = $$$OK 227 | Set tOldIO = $io 228 | Try { 229 | Set pOutput = $Get(pOutput) 230 | 231 | If $IsObject(pOutput) && pOutput.%IsA("%Stream.Object") { 232 | Set tSC = ..ReadToStream(.pOutput) 233 | } Else { 234 | Set tSC = ..ReadToString(.pOutput) 235 | } 236 | } Catch e { 237 | Set tSC = e.AsStatus() 238 | } 239 | 240 | // Close the XDEV device, and switch back to the original device 241 | Try { 242 | If (tOldIO = ..Device) { 243 | Do ..UsePreviousDeviceAndSettings() 244 | } Else { 245 | Use tOldIO 246 | } 247 | Close ..Device 248 | } Catch e { 249 | Set tSC = $$$ADDSC(tSC,e.AsStatus()) 250 | } 251 | Quit tSC 252 | } 253 | 254 | Method %OnClose() As %Status [ Private, ServerOnly = 1 ] 255 | { 256 | If (..Device '= "") { 257 | If ($io = ..Device) { 258 | // Switch back to the previous device if possible; if this fails, the subsequent 259 | // close of ..Device will switch back to the principal device. 260 | Try { 261 | Do ..UsePreviousDeviceAndSettings() 262 | } Catch e {} 263 | } 264 | Close ..Device 265 | } 266 | Set ..DeviceOpen = 0 267 | 268 | Quit $$$OK 269 | } 270 | 271 | Method UsePreviousDeviceAndSettings() [ Internal, Private ] 272 | { 273 | Use ..PreviousDevice 274 | If (..PreviousMnemonicRoutine '= "") { 275 | Set tOldMnemonic = "^"_..PreviousMnemonicRoutine 276 | Use ..PreviousDevice::(tOldMnemonic) 277 | } 278 | If ..PreviousIORedirectFlag { 279 | Do ##class(%Library.Device).ReDirectIO(1) 280 | } 281 | } 282 | 283 | } 284 | -------------------------------------------------------------------------------- /docs/user-guide.md: -------------------------------------------------------------------------------- 1 | # AppS.REST User Guide 2 | 3 | For a step-by-step tutorial, see [AppS.REST Tutorial and Sample Application: Contact List](sample-phonebook.md). 4 | 5 | ## Prerequisites 6 | 7 | AppS.REST requires InterSystems IRIS Data Platform 2018.1 or later. 8 | 9 | Installation is done via the [Community Package Manager](https://github.com/intersystems-community/zpm): 10 | 11 | zpm "install apps.rest" 12 | 13 | ## Getting Started 14 | 15 | ### Create and Configure a REST Handler 16 | 17 | Create a subclass of `AppS.REST.Handler`. AppS.REST extends %CSP.REST, and for the most part this subclass may include overrides the same as a subclass of %CSP.REST. 18 | 19 | For example, a user may add overrides to use the following %CSP.REST features: 20 | 21 | * The `UseSession` class parameter if CSP sessions should be used (by default, they are not, as CSP sessions are not stateless). 22 | * CORS-related parameters and methods if CORS support is required. 23 | 24 | However, **do not override the UrlMap XData block**; the routes are standardized and you should not need to edit/amend them. 25 | 26 | To augment an existing REST API with AppS.REST features, forward a URL from your existing REST handler to this subclass of AppS.REST.Handler. 27 | 28 | To create a new AppS.REST-based REST API, configure the subclass of AppS.REST.Handler as the Dispatch Class for a new web application. 29 | 30 | ### Define an Authentication Strategy 31 | 32 | If the web application uses password or delegated authentication, simply override the AuthenticationStrategy() method in the REST handler class as follows: 33 | 34 | ClassMethod AuthenticationStrategy() As %Dictionary.CacheClassname 35 | { 36 | Quit "AppS.REST.Authentication.PlatformBased" 37 | } 38 | 39 | If not (for example, because of a more complex token-based approach such as OAuth that does not use delegated authentication/authorization), create a subclass of `AppS.REST.Authentication` and override the `Authenticate`, `UserInfo`, and `Logout` methods as appropriate. 40 | For example, `Authenticate` might check for a bearer token and set `pContinue` to false if one is not present; `UserInfo` may return an OpenID Connect "userinfo" object, and `Logout` may invalidate/revoke an access token. In this case, the `AuthenticationStrategy` method in the `AppS.REST.Handler` subclass should return the name of the class implementing the authentication strategy. 41 | 42 | ### Define a User Resource 43 | 44 | If the application already has a class representing the user model, preferences, etc., consider providing a REST model for it as described below. Alternatively, for simple use cases, you may find it helpful to wrap platform security features in a registered object; see [UnitTest.AppS.REST.Sample.UserContext.cls](https://github.com/intersystems/apps-rest/blob/master/internal/testing/unit_tests/UnitTest/AppS/REST/Sample/UserContext.cls) for an example of this. 45 | 46 | In either approach, the `GetUserResource` method in the application's `AppS.REST.Handler` subclass should be overridden to return a new instance of this user model. For example: 47 | 48 | ClassMethod GetUserResource(pFullUserInfo As %DynamicObject) As UnitTest.AppS.REST.Sample.UserContext 49 | { 50 | Quit ##class(UnitTest.AppS.REST.Sample.UserContext).%New() 51 | } 52 | 53 | ### Authentication-related Endpoints 54 | 55 | HTTP Verb + Endpoint|Function 56 | ---|--- 57 | GET /auth/status|Returns information about the currently-logged-in user, if the user is authenticated, or an HTTP 401 if the user is not authenticated and authentication is required. 58 | POST /auth/logout|Invokes the authentication strategy's Logout method. (No body expected.) 59 | 60 | ## Defining REST Models 61 | 62 | AppS.REST provides for standardized access to both persistent data and business logic. 63 | 64 | ### Accessing Data: Adaptor vs. Proxy 65 | 66 | There are two different approaches to exposing persistent data over REST. The "Adaptor" approach provides a single REST representation for the existing `%Persistent` class. The "Proxy" approach provides a REST representation for a *different* `Persistent` class. 67 | 68 | #### AppS.REST.Model.Adaptor 69 | 70 | To expose data of a class that extends %Persistent over REST, simply extend `AppS.REST.Model.Adaptor` as well. Then, override the following class parameters: 71 | 72 | * `RESOURCENAME`: Set this to the URL prefix you want to use for the resource in a REST context (e.g., "person") 73 | * `JSONMAPPING` (optional): Defaults to empty (the class's default JSON mapping); set this to the name of a JSON mapping XData block 74 | * `MEDIATYPE` (optional): Defaults to "application/json"; may be overridden to specify a different media type (e.g., application/vnd.yourcompany.v1+json; should still be an application/json subtype) 75 | 76 | For an example of using AppS.REST.Model.Adaptor, see: [UnitTest.AppS.REST.Sample.Data.Vendor](https://github.com/intersystems/apps-rest/blob/master/internal/testing/unit_tests/UnitTest/AppS/REST/Sample/Data/Vendor.cls) 77 | 78 | #### AppS.REST.Model.Proxy 79 | 80 | To expose data of a *different* class that extends %Persistent over REST, perhaps using an alternative JSON mapping from other projections of the same data, extend `AppS.REST.Model.Proxy`. In addition to the same parameters as `AppS.REST.Model.Adaptor`, you must also override the `SOURCECLASS` parameter to specify a different class that extends both `%JSON.Adaptor` and `%Persistent`. 81 | 82 | For an example of using AppS.REST.Model.Proxy, see: [UnitTest.AppS.REST.Sample.Model.Person](https://github.com/intersystems/apps-rest/blob/master/internal/testing/unit_tests/UnitTest/AppS/REST/Sample/Model/Person.cls) 83 | 84 | ### Permissions 85 | 86 | Whether you extend `Adaptor` or `Proxy`, you must override the `CheckPermissions` method, which by default says that nothing is allowed: 87 | 88 | /// Checks the user's permission for a particular operation on a particular record. 89 | /// pOperation may be one of: 90 | /// CREATE 91 | /// READ 92 | /// UPDATE 93 | /// DELETE 94 | /// QUERY 95 | /// ACTION: 96 | /// pUserContext is supplied by GetUserContext 97 | ClassMethod CheckPermission(pID As %String, pOperation As %String, pUserContext As %RegisteredObject) As %Boolean 98 | { 99 | Quit 0 100 | } 101 | 102 | `pUserContext` is an instance of the user resource defined earlier. 103 | 104 | ### CRUD and Query Endpoints 105 | 106 | With resources defined as described above, the following endpoints are available: 107 | 108 | HTTP Verb / Endpoint|Function 109 | ---|--- 110 | GET /:resource | Returns an array of instances of the requested resource, subject to filtering criteria specified by URL parameters. 111 | POST /:resource | Saves a new instance of the specified resource (present in the request entity body) in the database. Responds with the JSON representation of the resource, including a newly-assigned ID, if relevant, as well as any fields populated when the record was saved. 112 | GET /:resource/:id | Retrieves an instance of the resource with the specified ID 113 | PUT /:resource/:id | Updates the specified instance of the resource in the database (based on ID, with the data present in the request entity body). Responds with the JSON representation of the resource, including any fields updated when the record was saved.​ 114 | DELETE /:resource/:id | Deletes the instance of the resource with the specified ID 115 | 116 | ### Actions 117 | 118 | "Actions" allow you to provide a REST projection of business logic (that is, ObjectScript methods and classmethods) and class queries (abstractions of more complex SQL) alongside the basic REST capabilities. To start, override the `Actions` XData block: 119 | 120 | XData ActionMap [ XMLNamespace = "http://www.intersystems.com/apps/rest/action" ] 121 | { 122 | } 123 | 124 | Note - Studio will help with code completion for XML in this namespace. 125 | 126 | [UnitTest.AppS.REST.Sample.Model.Person](https://github.com/intersystems/apps-rest/blob/master/internal/testing/unit_tests/UnitTest/AppS/REST/Sample/Model/Person.cls) has annotated examples covering the full range of action capabilities. As a general guideline, do ensure that the HTTP verb matches the behavior of the endpoint (e.g., PUT and DELETE are idempotent, GET is safe, POST is neither). 127 | 128 | #### Action Endpoints 129 | 130 | HTTP Verbs + Endpoint|Function 131 | ---|--- 132 | GET, PUT, POST, DELETE /:resource/$:action|Performs the named action on the specified resource. Constraints and format of URL parameters, body, and response contents will vary from action to action, but are well-defined. 133 | GET, PUT, POST, DELETE /:resource/:id/$:action|Performs the named action on the specified resource instance. Constraints and format of URL parameters, body, and response contents will vary from action to action, but are well-defined. 134 | 135 | ## Related Topics in InterSystems Documentation 136 | 137 | * [Using the JSON Adaptor](https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=GJSON_adaptor) 138 | * [Introduction to Creating REST Services](https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=GREST_intro) 139 | * [Supporting CORS in REST Services](https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=GREST_cors) 140 | * [Securing REST Services](https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=GREST_securing) 141 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/Action/t/action.cls: -------------------------------------------------------------------------------- 1 | Include %occClassname 2 | 3 | /// This class is internal to AppS.REST; consumers should not use or reference it directly. 4 | Class AppS.REST.Model.Action.t.action Extends (%RegisteredObject, %XML.Adaptor) [ System = 2 ] 5 | { 6 | 7 | Parameter XMLIGNOREINVALIDATTRIBUTE As BOOLEAN = 0; 8 | 9 | Parameter NAMESPACE As STRING = "http://www.intersystems.com/apps/rest/action"; 10 | 11 | Property arguments As list Of AppS.REST.Model.Action.t.argument(XMLNAME = "argument", XMLPROJECTION = "element"); 12 | 13 | /// Name of the action (used in URLs) 14 | Property name As %String(MAXLEN = 255, XMLPROJECTION = "attribute") [ Required ]; 15 | 16 | /// Whether the action targets the class or instance. Default is "class". 17 | Property target As %String(VALUELIST = ",class,instance", XMLPROJECTION = "attribute") [ InitialExpression = "class", Required ]; 18 | 19 | /// The method to call - either the name of a class/instance method, or (if in another class) may take the format classname:methodname 20 | Property call As %String(MAXLEN = 255, XMLPROJECTION = "attribute"); 21 | 22 | /// The class query to wrap. May just be the query name if the query is in the current class, or classname:queryname if in a different class. 23 | Property query As %String(MAXLEN = 255, XMLPROJECTION = "attribute"); 24 | 25 | /// The method to use for the action. Defaults to POST, as this will be most common. 26 | Property method As %String(VALUELIST = ",GET,POST,PUT,DELETE", XMLPROJECTION = "attribute") [ InitialExpression = "POST", Required ]; 27 | 28 | /// For queries, the model class of result instances (if different from the source class) 29 | Property modelClass As %Dictionary.CacheClassname(XMLPROJECTION = "attribute"); 30 | 31 | /// This callback method is invoked by the %ValidateObject method to 32 | /// provide notification that the current object is being validated. 33 | /// 34 | ///

If this method returns an error then %ValidateObject will fail. 35 | Method %OnValidateObject() As %Status [ Private, ServerOnly = 1 ] 36 | { 37 | If (..call = "") = (..query = "") { 38 | // If call and query are both empty or both nonempty, we have a problem. 39 | Set msg = $$$FormatText("Action %1: must specify either a call or a query to use.",..name) 40 | Quit $$$ERROR($$$GeneralError,msg) 41 | } 42 | 43 | If (..query '= "") { 44 | If (..method = "POST") { 45 | Set msg = $$$FormatText("Action %1: must use GET method with a query.",..name) 46 | Quit $$$ERROR($$$GeneralError,msg) 47 | } 48 | } Else { 49 | If (..modelClass '= "") { 50 | Set msg = $$$FormatText("Action %1: modelClass may only be defined for query actions",..name) 51 | Quit $$$ERROR($$$GeneralError,msg) 52 | } 53 | } 54 | 55 | Set sc = $$$OK 56 | For i=1:1:..arguments.Count() { 57 | Set sc = $$$ADDSC(sc,..arguments.GetAt(i).%ValidateObject()) 58 | } 59 | Quit sc 60 | } 61 | 62 | Method GetDependencies(pSourceClass As %String, ByRef pClassArray) 63 | { 64 | // Always depends on the source class. 65 | Set pClassArray(pSourceClass) = "" 66 | 67 | If (..modelClass '= "") { 68 | Set pClassArray(..modelClass) = "" 69 | } 70 | 71 | // Other dependencies 72 | If (..query '= "") { 73 | If $Length(..query,":") > 1 { 74 | Set pClassArray($Piece(..query,":")) = "" 75 | } 76 | } Else { 77 | If $Length(..call,":") > 1 { 78 | Set class = $Piece(..call,":") 79 | Set method = $Piece(..call,":",2) 80 | Set pClassArray(class) = "" 81 | } Else { 82 | Set class = pSourceClass 83 | Set method = ..call 84 | } 85 | Set formalspec = $$$comMemberKeyGet(class,$$$cCLASSmethod,method,$$$cMETHformalspecparsed) 86 | Set pointer = 0 87 | While $ListNext(formalspec, pointer, argument) { 88 | If $Data(argument)#2 && ($ListGet(argument,2) '= "") { 89 | Set pClassArray($$$NormalizeClassname($ListGet(argument,2))) = "" 90 | } 91 | } 92 | Set returnType = $$$comMemberKeyGet(class,$$$cCLASSmethod,method,$$$cMETHreturntype) 93 | If (returnType '= "") { 94 | Set pClassArray($$$NormalizeClassname(returnType)) = "" 95 | } 96 | } 97 | } 98 | 99 | Method Generate(pSourceClass As %String, Output pCodeArray, Output pAccepts As %String, Output pContentType As %String) 100 | { 101 | #define GENERATE(%line) Set pCodeArray($i(pCodeArray)) = $c(9)_%line 102 | 103 | Kill pCodeArray 104 | Set pAccepts = "" 105 | Set pContentType = "" 106 | Set tResultClass = pSourceClass 107 | Do ..GetFormalSpecMap(.tResultClass, .class, .method, .argArray, .nameMap, .returnType) 108 | 109 | $$$GENERATE("// "_..method_" $"_..name) 110 | 111 | Set pCodeArray($i(pCodeArray)) = $c(9)_"Set args = "_$Get(argArray,0) 112 | 113 | For argIndex = 1:1:..arguments.Count() { 114 | Set argument = ..arguments.GetAt(argIndex) 115 | Set position = nameMap(argument.target) 116 | Set argType = $ListGet(argArray(position),2) 117 | If (argument.source = "url") { 118 | If (argument.required) { 119 | $$$GENERATE("Set args("_position_") = %request.Get("_$$$QUOTE(argument.name)_")") 120 | $$$GENERATE("If (args("_position_") = """") {") 121 | $$$GENERATE(" Set %response.Status = "_$$$QUOTE(##class(%CSP.REST).#HTTP400BADREQUEST)) 122 | $$$GENERATE(" Return") 123 | $$$GENERATE("}") 124 | } Else { 125 | $$$GENERATE("Merge args("_position_") = %request.Data("_$$$QUOTE(argument.name)_",1)") 126 | } 127 | } ElseIf (argument.source = "body") { 128 | If ('argument.required) { 129 | $$$GENERATE("If %request.Content.Size = 0 {") 130 | $$$GENERATE(" Set args("_position_") = $$$NULLOREF") 131 | $$$GENERATE("} Else {") 132 | } 133 | If $ClassMethod(argType,"%Extends","%Library.DynamicAbstractObject") { 134 | Set pAccepts = "application/json" 135 | $$$GENERATE("Set args("_position_") = {}.%FromJSON(%request.Content)") 136 | } ElseIf $ClassMethod(argType,"%Extends","AppS.REST.Model.Resource") { 137 | Set pAccepts = $Parameter(argType,"MEDIATYPE") 138 | $$$GENERATE("Set model = ##class("_argType_").GetModelInstance()") 139 | $$$GENERATE("$$$ThrowOnError(model.%JSONImport(%request.Content))") 140 | $$$GENERATE("Set args("_position_") = model") 141 | } ElseIf $Parameter(argType,"%JSONENABLED") { 142 | Set pAccepts = "application/json" 143 | $$$GENERATE("Set model = ##class("_argType_").%New()") 144 | $$$GENERATE("$$$ThrowOnError(model.%JSONImport(%request.Content))") 145 | $$$GENERATE("Set args("_position_") = model") 146 | } 147 | If ('argument.required) { 148 | $$$GENERATE("}") 149 | } 150 | } ElseIf (argument.source = "id") { 151 | // TODO: support alternative ID fields / method in AppS.REST.Model.Resource to get the ID of an instance 152 | // This approach is lazy and assumes %Persistent/AppS.REST.Model.Adaptor 153 | $$$GENERATE(" Set args("_position_") = pInstance.%Id()") 154 | } 155 | } 156 | 157 | If (..query '= "") { 158 | Set methodCall = "set result = ##class(AppS.REST.Model.QueryResult).FromClassQuery("_$$$QUOTE(tResultClass)_","_$$$QUOTE(class)_","_$$$QUOTE(method)_",args...)" 159 | } Else { 160 | If (returnType = "") { 161 | Set methodCall = "Do " 162 | } Else { 163 | Set methodCall = "Set result = " 164 | } 165 | Set methodCall = methodCall_$Case(..target, 166 | "instance":"pInstance.", 167 | "class":"##class("_class_").")_method_"(args...)" 168 | } 169 | $$$GENERATE(methodCall) 170 | If (returnType = "%Library.Status") { 171 | $$$GENERATE("$$$ThrowOnError(result)") 172 | } ElseIf (returnType '= "") { 173 | If $ClassMethod(returnType,"%Extends","%Library.DynamicAbstractObject") { 174 | Set pContentType = "application/json" 175 | Set exportCommand = "Write result.%ToJSON()" 176 | } ElseIf $ClassMethod(returnType,"%Extends","AppS.REST.Model.Resource") { 177 | Set pContentType = $Parameter(returnType,"MEDIATYPE") 178 | Set exportCommand = "Do result.JSONExport()" 179 | } ElseIf $ClassMethod(returnType,"%Extends","AppS.REST.Model.QueryResult") { 180 | Set pContentType = $Parameter(tResultClass,"MEDIATYPE") 181 | Set exportCommand = "Do result.JSONExport()" 182 | } ElseIf $Parameter(returnType,"%JSONENABLED") { 183 | Set pContentType = "application/json" 184 | Set exportCommand = "Do result.%JSONExport()" 185 | } Else { 186 | $$$GENERATE("// Unknown handling for return type: "_returnType) 187 | $$$GENERATE("Set %response.Status = "_$$$QUOTE(##class(%CSP.REST).#HTTP204NOCONTENT)) 188 | Quit 189 | } 190 | $$$GENERATE("Set %response.ContentType = "_$$$QUOTE(pContentType)) 191 | $$$GENERATE("If $IsObject(result) {") 192 | $$$GENERATE(" "_exportCommand) 193 | $$$GENERATE("} Else {") 194 | $$$GENERATE(" Set %response.Status = "_$$$QUOTE(##class(%CSP.REST).#HTTP204NOCONTENT)) 195 | $$$GENERATE("}") 196 | } 197 | } 198 | 199 | Method GetFormalSpecMap(ByRef pModelClass As %String, Output pClass, Output pMethod, Output pArgArray, Output pNameMap, Output pReturnType) 200 | { 201 | Kill pClass,pMethod,pArgArray,pNameMap,returnType 202 | 203 | If (..query '= "") { 204 | // Class Query - method = query name 205 | If $Length(..query,":") > 1 { 206 | Set pClass = $Piece(..query,":") 207 | Set pMethod = $Piece(..query,":",2) 208 | } Else { 209 | Set pClass = pModelClass 210 | Set pMethod = ..query 211 | } 212 | 213 | Set formalspec = $$$comMemberKeyGet(pClass,$$$cCLASSquery,pMethod,$$$cQUERYformalspecparsed) 214 | Set pointer = 0 215 | While $ListNext(formalspec, pointer, argument) { 216 | Set pArgArray($Increment(pArgArray)) = $List(argument,1,2) 217 | Set pNameMap($ListGet(argument)) = pArgArray 218 | } 219 | If (..modelClass '= "") { 220 | Set pModelClass = ..modelClass 221 | } 222 | Set pReturnType = "AppS.REST.Model.QueryResult" 223 | } Else { 224 | // Normal method call 225 | If $Length(..call,":") > 1 { 226 | Set pClass = $Piece(..call,":") 227 | Set pMethod = $Piece(..call,":",2) 228 | } Else { 229 | Set pClass = pModelClass 230 | Set pMethod = ..call 231 | } 232 | 233 | Set formalspec = $$$comMemberKeyGet(pClass,$$$cCLASSmethod,pMethod,$$$cMETHformalspecparsed) 234 | Set pointer = 0 235 | While $ListNext(formalspec, pointer, argument) { 236 | Set pArgArray($Increment(pArgArray)) = $List(argument,1,2) 237 | Set pNameMap($ListGet(argument)) = pArgArray 238 | } 239 | Set pReturnType = $$$NormalizeClassname($$$comMemberKeyGet(pClass,$$$cCLASSmethod,pMethod,$$$cMETHreturntype)) 240 | } 241 | } 242 | 243 | } 244 | -------------------------------------------------------------------------------- /cls/AppS/REST/Model/DBMappedResource.cls: -------------------------------------------------------------------------------- 1 | /// Base class for all models that directly correspond to data from the database (e.g., AppS.REST.Model.Proxy and AppS.REST.Model.Adaptor) 2 | Class AppS.REST.Model.DBMappedResource Extends (AppS.REST.Model.Resource, %JSON.Adaptor) [ Abstract, System = 3 ] 3 | { 4 | 5 | /// The class to which this class provides REST access. It must extend %Persistent and have its %JSONENABLED class parameter set to 1 (e.g., by extending %JSON.Adaptor). 6 | /// Subclasses must override this parameter. 7 | Parameter SOURCECLASS As STRING [ Abstract ]; 8 | 9 | /// The JSON mapping of the related JSON-enabled class to use. 10 | /// Defaults to empty (the default mapping for the associated class). 11 | Parameter JSONMAPPING As STRING [ Abstract ]; 12 | 13 | /// Override to use an alternative JSON generator 14 | Parameter JSONGENERATOR As CLASSNAME = "%JSON.Generator"; 15 | 16 | /// Override to use an alternative unique index name 17 | Parameter IndexToUse As STRING = "ID"; 18 | 19 | /// If true, the whole SQL result row is passed to GetModelInstance rather than just the ID. 20 | /// If true, GetModelFromResultRow must be overridden and implemented. 21 | Parameter ConstructFromResultRow As BOOLEAN = 0; 22 | 23 | /// Returns an existing instance of this model, based on the identifier index, or a new instance if index is not supplied. 24 | ClassMethod GetModelInstance(index As %String) As AppS.REST.Model.DBMappedResource 25 | { 26 | // We might want to pass in an entire row (an object) instead of just an index to look up 27 | // If that happens, use this method instead 28 | If $IsObject($Get(index)) { 29 | Return ..GetModelFromResultRow(index) 30 | } Else { 31 | If $Data(index)#2 { 32 | Set method = "%OpenId" 33 | If (..#IndexToUse '= "ID") { 34 | Set method = ..#IndexToUse _ "Open" 35 | } 36 | Set object = $classmethod(..#SOURCECLASS, method, index, , .tSC) 37 | $$$ThrowOnError(tSC) 38 | } Else { 39 | Set object = $classmethod(..#SOURCECLASS, "%New") 40 | } 41 | 42 | Return ..GetModelFromObject(object) 43 | } 44 | } 45 | 46 | /// May be overridden to get an instance of this class from a result set row instead of an object. 47 | /// This is particularly useful for loading data from a linked table, where individual reads are expensive. 48 | ClassMethod GetModelFromResultRow(pResultRow As %Library.IResultSet) As AppS.REST.Model.DBMappedResource 49 | { 50 | $$$ThrowStatus($$$ERROR($$$NotImplemented)) 51 | } 52 | 53 | /// Uses the data from a persistent object to populate the properties of this model.

54 | /// The object argument is a %Persistent object of whatever class is specified by SOURCECLASS. 55 | /// It was either opened with %OpenId, opened with an IndexOpen method, or created with %New().

56 | /// This method should instantiate an instance of this model with ..%New(), populate its properties 57 | /// using object, and then return the model. For example, a subclass implementation might look something like:

 58 | /// set myModel = ..%New()
 59 | /// set myModel.name = object.DisplayName
 60 | /// set myModel.alive = 'object.isDead()
 61 | /// set myModel.age = object.age
 62 | /// return myModel
 63 | /// 
64 | /// This method must be overwritten by subclasses. 65 | ClassMethod GetModelFromObject(object As %Persistent) As AppS.REST.Model.DBMappedResource [ Abstract ] 66 | { 67 | } 68 | 69 | /// Saves the model instance 70 | Method SaveModelInstance(pUserContext As %RegisteredObject) [ Abstract ] 71 | { 72 | } 73 | 74 | /// Deletes an instance of this model, based on the identifier pID 75 | ClassMethod DeleteModelInstance(pID As %String) As %Boolean [ Abstract ] 76 | { 77 | } 78 | 79 | /// Since a proxy connects directly to a %persistent class, getting a collection constitutes 80 | /// building and running a query, and then printing out the result set in a json format. 81 | ClassMethod GetCollection(ByRef URLParams) 82 | { 83 | Set resultSet = ..ConstructAndRunQuery(.URLParams) 84 | Write "[" 85 | 86 | Set useResultRow = ..#ConstructFromResultRow 87 | While resultSet.%Next(.sc) { 88 | $$$ThrowOnError(sc) 89 | Set tProxy = ..GetModelInstance($Select(useResultRow:resultSet, 90 | 1:resultSet.%Get(..#IndexToUse))) 91 | If ($Increment(count) > 1) { 92 | Write "," 93 | } 94 | Do tProxy.JSONExport() 95 | } 96 | $$$ThrowOnError(sc) 97 | 98 | Write "]" 99 | } 100 | 101 | ClassMethod ConstructAndRunQuery(ByRef URLParams) [ Internal, Private ] 102 | { 103 | Set query = ##class(AppS.REST.QueryGenerator).GetQuery(..#SOURCECLASS, ..GetProxyColumnList(), ..#IndexToUse, .URLParams, .queryParams) 104 | 105 | Set tStatement = ##class(%SQL.Statement).%New() 106 | Set qStatus = tStatement.%Prepare(query) 107 | $$$ThrowOnError(qStatus) 108 | 109 | Set rset = tStatement.%Execute(queryParams...) 110 | If (rset.%SQLCODE < 0) { 111 | Throw ##class(%Exception.SQL).CreateFromSQLCODE(rset.%SQLCODE,rset.%Message) 112 | } 113 | Return rset 114 | } 115 | 116 | /// JSONImport imports JSON or dynamic object input into this object.
117 | /// The input argument is either JSON as a string or stream, or a subclass of %DynamicAbstractObject. 118 | Method JSONImport(input) As %Status 119 | { 120 | Quit ..%JSONImport(.input, ..#JSONMAPPING) 121 | } 122 | 123 | /// Serialize a JSON enabled class as a JSON document and write it to the current device. 124 | Method JSONExport() As %Status 125 | { 126 | Quit ..%JSONExport(..#JSONMAPPING) 127 | } 128 | 129 | /// Serialize a JSON enabled class as a JSON document and write it to a stream. 130 | Method JSONExportToStream(ByRef export As %Stream.Object) As %Status 131 | { 132 | Quit ..%JSONExportToStream(.export, ..#JSONMAPPING) 133 | } 134 | 135 | /// Serialize a JSON enabled class as a JSON document and return it as a string. 136 | Method JSONExportToString(ByRef %export As %String) As %Status 137 | { 138 | Quit ..%JSONExportToString(.%export, ..#JSONMAPPING) 139 | } 140 | 141 | /// Subclasses should not need to override this method. Instead, implement OnGetProxyColumnList. 142 | ClassMethod GetProxyColumnList() As %DynamicObject [ CodeMode = objectgenerator, Internal ] 143 | { 144 | Set sc = $$$OK 145 | Try { 146 | If $$$comClassKeyGet(%compiledclass.Name,$$$cCLASSabstract) { 147 | // Don't generate. 148 | Quit 149 | } 150 | 151 | Set class = %parameter("SOURCECLASS") 152 | If (class = "$classname()") { 153 | // Special case, replace with current class name. 154 | // TODO: More generic COSEXPRESSION handling? 155 | Set class = %compiledclass.Name 156 | } 157 | If '$$$comMemberKeyGet(class,$$$cCLASSparameter,"%JSONENABLED",$$$cPARAMdefault) { 158 | $$$ThrowStatus($$$ERROR($$$GeneralError,"Class '" _ class _ "' must be JSON-enabled.")) 159 | } 160 | Set mapping = $lb() // Key of default mapping in %JSON.Generator:GenerateMapping* 161 | Set mappingParam = $Case(%parameter("JSONMAPPING"), "":$lb() /*default mapping subscript*/, :%parameter("JSONMAPPING")) 162 | Set jsonGenerator = %parameter("JSONGENERATOR") 163 | 164 | If (jsonGenerator = "") { 165 | Do %code.WriteLine(" Quit {}") 166 | Quit 167 | } 168 | 169 | // Call JSON generator methods 170 | $$$ThrowOnError($ClassMethod(jsonGenerator,"GenerateMapping",class,.mapping)) 171 | $$$ThrowOnError($ClassMethod(jsonGenerator,"GenerateMappingFromXdata",class,.mapping)) 172 | 173 | If '$Data(mapping(mappingParam)) { 174 | Do %code.WriteLine(" Quit {}") 175 | Quit 176 | } 177 | 178 | // Build map of JSON mapping column name -> SQL field name 179 | // Sample value from mapping variable: 180 | // mapping("FooBarMapping",1)=$lb("Foo","Foo","inout","someField",0,0,"",0,"string","LITERAL","%Library.String",) 181 | Set fieldIndex = 0 // SKip 0 subscript 182 | For { 183 | Set fieldIndex = $Order(mapping(mappingParam,fieldIndex),1,fieldInfo) 184 | If (fieldIndex = "") { 185 | Quit 186 | } 187 | 188 | // Skip properties with projection "none" or non-"LITERAL" types. 189 | If ($ListGet(fieldInfo,3) = "none") { 190 | Continue 191 | } 192 | If ($ListGet(fieldInfo,10) '= "LITERAL") { 193 | Continue 194 | } 195 | 196 | Set fields($ListGet(fieldInfo,2)) = $ListGet(fieldInfo,4) 197 | } 198 | 199 | Set compiledClass = ##class(%Dictionary.CompiledClass).%OpenId(class,,.sc) 200 | $$$ThrowOnError(sc) 201 | 202 | Do %code.WriteLine(" Set columns = {}") 203 | Set propKey = "" 204 | For { 205 | #dim prop As %Dictionary.CompiledProperty 206 | Set prop = compiledClass.Properties.GetNext(.propKey) 207 | Quit:(prop = "") 208 | Continue:(prop.Name = "%%OID") 209 | Continue:(prop.Name = "%Concurrency") 210 | Continue:(prop.Relationship) 211 | Continue:(prop.Collection '= "") 212 | Continue:(prop.Transient) 213 | Continue:(prop.MultiDimensional) 214 | 215 | If $Data(fields(prop.Name),propInfo) { 216 | Set name = $Case(prop.SqlFieldName,"":prop.Name,:prop.SqlFieldName) 217 | Do %code.WriteLine(" Do columns.%Set(" _ $$$QUOTE(propInfo) _ "," _ $$$QUOTE(name) _ ")") 218 | } 219 | } 220 | Do %code.WriteLine(" Do ..OnGetProxyColumnList(columns)") 221 | Do %code.WriteLine(" Return columns") 222 | } Catch e { 223 | Set sc = e.AsStatus() 224 | } 225 | Quit sc 226 | } 227 | 228 | /// Subclasses may override this method to modify the permitted set of columns/aliases for use in queries 229 | /// (by default, this is the full set of exposed properties in the model with their JSON aliases) 230 | ClassMethod OnGetProxyColumnList(pColumnList As %DynamicObject) 231 | { 232 | } 233 | 234 | /// Subclasses may override this method to do additional security checks or otherwise make changes to the model before it is saved. 235 | /// Primarily relevant for subclasses of AppS.REST.Model.Proxy and AppS.REST.Model.Adaptor. 236 | /// Should throw an exception if an error occurs - e.g., $$$ThrowStatus($$$ERROR($$$AccessDenied)) 237 | Method OnBeforeSaveModel(pUserContext As %RegisteredObject) 238 | { 239 | } 240 | 241 | /// Subclasses may override this method to do additional security checks or otherwise make changes to the model before it is saved. 242 | /// Primarily relevant for subclasses of AppS.REST.Model.Proxy and AppS.REST.Model.Adaptor. 243 | /// Should throw an exception if an error occurs - e.g., $$$ThrowStatus($$$ERROR($$$AccessDenied)) 244 | Method OnAfterSaveModel(pUserContext As %RegisteredObject) 245 | { 246 | } 247 | 248 | } 249 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/UnitTest/AppS/REST/DriveSample.cls: -------------------------------------------------------------------------------- 1 | /// To keep the web application around after the test runs (for manual debugging): 2 | /// 3 | /// zpm 4 | /// apps.rest test -only -DUnitTest.Case=UnitTest.AppS.REST.DriveSample -DUnitTest.UserParam.KeepApplication=1 -verbose 5 | /// 6 | Class UnitTest.AppS.REST.DriveSample Extends %UnitTest.TestCase 7 | { 8 | 9 | Property ForgeryInstalled As %Boolean [ InitialExpression = {$$$comClassDefined("Forgery.Agent")} ]; 10 | 11 | Property WebAppName As %String [ InitialExpression = {"/csp/"_$ZConvert($Namespace,"L")_"/unit-test/sample"} ]; 12 | 13 | Property Agent As %RegisteredObject; 14 | 15 | Property Person As %DynamicAbstractObject; 16 | 17 | Property Vendor As %DynamicAbstractObject; 18 | 19 | Method OnBeforeAllTests() As %Status 20 | { 21 | Set sc = $$$OK 22 | Set oldNamespace = $Namespace 23 | Try { 24 | // Generate data for tests 25 | Do ##class(UnitTest.AppS.REST.Sample.Data.Utils).Generate() 26 | 27 | New $Namespace 28 | Set $Namespace = "%SYS" 29 | Set props("NameSpace") = oldNamespace 30 | Set props("DispatchClass") = "UnitTest.AppS.REST.Sample.Handler" 31 | Set props("CookiePath") = ..WebAppName_"/" 32 | Set props("Recurse") = 1 33 | Set props("IsNameSpaceDefault") = 0 34 | Set props("AutheEnabled") = 32 // Password 35 | If ##class(Security.Applications).Exists(..WebAppName) { 36 | $$$ThrowOnError(##class(Security.Applications).Modify(..WebAppName, .props)) 37 | } Else { 38 | $$$ThrowOnError(##class(Security.Applications).Create(..WebAppName, .props)) 39 | } 40 | } Catch e { 41 | Set sc = e.AsStatus() 42 | } 43 | Quit sc 44 | } 45 | 46 | Method OnBeforeOneTest(testname As %String) As %Status 47 | { 48 | Set ..SkipTest = (testname '= "TestCompiling") && '..ForgeryInstalled 49 | If ..SkipTest { 50 | Do $$$AssertSkipped("Must install the Forgery project from the Open Exchange to support running certain REST tests.") 51 | } Else { 52 | Set ..Agent = ##class(Forgery.Agent).%New(..WebAppName_"/", { 53 | "Content-Type": "application/json; charset=utf-8", 54 | "Accept": "application/json" 55 | }) 56 | } 57 | Quit $$$OK 58 | } 59 | 60 | Method OnAfterAllTests() As %Status 61 | { 62 | Set sc = $$$OK 63 | Set oldNamespace = $Namespace 64 | Try { 65 | If '..Manager.UserFields.GetAt("KeepApplication") { 66 | New $Namespace 67 | Set $Namespace = "%SYS" 68 | $$$ThrowOnError(##class(Security.Applications).Delete(..WebAppName)) 69 | } 70 | } Catch e { 71 | Set sc = e.AsStatus() 72 | } 73 | Quit sc 74 | } 75 | 76 | Method TestAuthStatus() 77 | { 78 | Do $$$AssertStatusOK(..Agent.Get({"url": "auth/status"}, .jsonStream)) 79 | Set response = ..Agent.GetLastResponse() 80 | Do $$$AssertEquals(response.Status, "200 OK") 81 | Do $$$AssertEquals(response.ContentType, "application/json") 82 | Set object = {}.%FromJSON(jsonStream) 83 | Do $$$AssertEquals(object.Username,$Username) 84 | Do $$$AssertEquals(object.IsAdmin,(","_$Roles_",") [ ",%All,") 85 | } 86 | 87 | Method TestCompiling() 88 | { 89 | Do $$$AssertStatusOK($System.OBJ.CompilePackage("UnitTest.AppS.REST.Sample", "ck/nomulticompile")) 90 | } 91 | 92 | Method TestPerson01Query() 93 | { 94 | Do $$$AssertStatusOK(..Agent.Get({"url": "person?$orderBy=name"}, .jsonStream)) 95 | Set response = ..Agent.GetLastResponse() 96 | Do $$$AssertEquals(response.Status, "200 OK") 97 | Do $$$AssertEquals(response.ContentType, "application/json") 98 | Set object = {}.%FromJSON(jsonStream) 99 | If '$$$AssertEquals(object.%Size(), 200) { 100 | Do $$$LogMessage("Response: "_object.%ToJSON()) 101 | } 102 | } 103 | 104 | Method TestPerson02Get() 105 | { 106 | Do $$$AssertStatusOK(..Agent.Get({"url": "person/1"}, .jsonStream)) 107 | Set response = ..Agent.GetLastResponse() 108 | Do $$$AssertEquals(response.Status, "200 OK") 109 | Do $$$AssertEquals(response.ContentType, "application/json") 110 | Set object = {}.%FromJSON(jsonStream) 111 | Set ..Person = object 112 | } 113 | 114 | Method TestPerson03Put() 115 | { 116 | Set ..Person.name = "Rubble, Barney" 117 | Do $$$AssertStatusOK(..Agent.Put({"url": "person/1", "data": (..Person)}, .jsonStream)) 118 | Set response = ..Agent.GetLastResponse() 119 | Do $$$AssertEquals(response.Status, "200 OK") 120 | Do $$$AssertEquals(response.ContentType, "application/json") 121 | Set object = {}.%FromJSON(jsonStream) 122 | Do $$$AssertEquals(object.name, "Rubble, Barney") 123 | } 124 | 125 | Method TestPerson04Post() 126 | { 127 | Do $$$AssertStatusOK(..Agent.Post({"url": "person", "data": {"name": "Flintstone, Fred"}}, .jsonStream)) 128 | Set response = ..Agent.GetLastResponse() 129 | Do $$$AssertEquals(response.Status, "200 OK") 130 | Do $$$AssertEquals(response.ContentType, "application/json") 131 | Set object = {}.%FromJSON(jsonStream) 132 | Do $$$AssertEquals(object.name, "Flintstone, Fred") 133 | } 134 | 135 | Method TestPerson05ListByName() 136 | { 137 | Do $$$AssertStatusOK(..Agent.Get({"url": "person/$list-by-name?name=Flintstone"}, .jsonStream)) 138 | Set response = ..Agent.GetLastResponse() 139 | Do $$$AssertEquals(response.Status, "200 OK") 140 | Set object = {}.%FromJSON(jsonStream) 141 | Do $$$LogMessage("Response: "_object.%ToJSON()) 142 | Do $$$AssertEquals(object.%Size(), 1) 143 | } 144 | 145 | Method TestPerson06UpdateHomeAddress() 146 | { 147 | Do $$$AssertStatusOK(..Agent.Put({"url": "person/201/$update-home-address","data":{"Zip":"12345"}}, .jsonStream)) 148 | Set response = ..Agent.GetLastResponse() 149 | Do $$$AssertEquals(response.Status, "200 OK") 150 | Set object = {}.%FromJSON(jsonStream) 151 | Do $$$LogMessage("Response: "_object.%ToJSON()) 152 | Do $$$AssertEquals(object.Zip,"12345") 153 | } 154 | 155 | Method TestPerson07UpdateOfficeAddress() 156 | { 157 | Do $$$AssertStatusOK(..Agent.Post({"url": "person/201/$update-office-address","data":{"Zip":"12345"}}, .jsonStream)) 158 | Set response = ..Agent.GetLastResponse() 159 | Do $$$AssertEquals(response.Status, "200 OK") 160 | Set object = {}.%FromJSON(jsonStream) 161 | Do $$$LogMessage("Response: "_object.%ToJSON()) 162 | Do $$$AssertEquals(object.name, "Flintstone, Fred") 163 | Do $$$AssertEquals(object."office_address".Zip, "12345") 164 | } 165 | 166 | Method TestPerson08Ping() 167 | { 168 | Do $$$AssertStatusOK(..Agent.Post({"url": "person/$ping", "data": {"foo":"bar"}}, .jsonStream)) 169 | Set response = ..Agent.GetLastResponse() 170 | Do $$$AssertEquals(response.Status, "200 OK") 171 | Set object = {}.%FromJSON(jsonStream) 172 | Do $$$LogMessage("Response: "_object.%ToJSON()) 173 | Do $$$AssertEquals(object.foo, "bar") 174 | } 175 | 176 | Method TestPerson09Delete() 177 | { 178 | Do $$$AssertStatusOK(..Agent.Delete({"url": "person/201"}, .jsonStream)) 179 | Set response = ..Agent.GetLastResponse() 180 | Do $$$AssertEquals(response.Status, "204 No Content") 181 | Do $$$AssertStatusOK(..Agent.Delete({"url": "person/201"}, .jsonStream)) 182 | Set response = ..Agent.GetLastResponse() 183 | Do $$$AssertEquals(response.Status, "404 Not Found") 184 | } 185 | 186 | Method TestVendor1Query() 187 | { 188 | Do $$$AssertStatusOK(..Agent.Get({"url": "vendor"}, .jsonStream)) 189 | Set response = ..Agent.GetLastResponse() 190 | Do $$$AssertEquals(response.Status, "200 OK") 191 | Do $$$AssertEquals(response.ContentType, "application/json") 192 | Set object = {}.%FromJSON(jsonStream) 193 | Do $$$AssertEquals(object.%Size(), 100) 194 | } 195 | 196 | Method TestVendor2Get() 197 | { 198 | Do $$$AssertStatusOK(..Agent.Get({"url": "vendor/1"}, .jsonStream)) 199 | Set response = ..Agent.GetLastResponse() 200 | Do $$$AssertEquals(response.Status, "200 OK") 201 | Do $$$AssertEquals(response.ContentType, "application/json") 202 | Set object = {}.%FromJSON(jsonStream) 203 | Set ..Vendor = object 204 | } 205 | 206 | Method TestVendor3Put() 207 | { 208 | Set ..Vendor.Name = "Acme Pharmaceuticals" 209 | Do $$$AssertStatusOK(..Agent.Put({"url": "vendor/1", "data": (..Vendor)}, .jsonStream)) 210 | Set response = ..Agent.GetLastResponse() 211 | Do $$$AssertEquals(response.Status, "200 OK") 212 | Do $$$AssertEquals(response.ContentType, "application/json") 213 | Set object = {}.%FromJSON(jsonStream) 214 | Do $$$AssertEquals(object.Name, "Acme Pharmaceuticals") 215 | } 216 | 217 | Method TestVendor4Post() 218 | { 219 | Set ..Vendor.Name = "Acme Robotics" 220 | Do $$$AssertStatusOK(..Agent.Post({"url": "vendor", "data": (..Vendor)}, .jsonStream)) 221 | Set response = ..Agent.GetLastResponse() 222 | Do $$$AssertEquals(response.Status, "200 OK") 223 | Do $$$AssertEquals(response.ContentType, "application/json") 224 | Set object = {}.%FromJSON(jsonStream) 225 | Do $$$AssertEquals(object.Name, "Acme Robotics") 226 | } 227 | 228 | Method TestVendor5Delete() 229 | { 230 | Do $$$AssertStatusOK(..Agent.Delete({"url": "vendor/101"}, .jsonStream)) 231 | Set response = ..Agent.GetLastResponse() 232 | Do $$$AssertEquals(response.Status, "204 No Content") 233 | Do $$$AssertStatusOK(..Agent.Delete({"url": "vendor/101"}, .jsonStream)) 234 | Set response = ..Agent.GetLastResponse() 235 | Do $$$AssertEquals(response.Status, "404 Not Found") 236 | } 237 | 238 | Method TestVendor6Construct() 239 | { 240 | Do $$$AssertStatusOK(..Agent.Get({"url": "vendor/$new"}, .jsonStream)) 241 | Set response = ..Agent.GetLastResponse() 242 | Do $$$AssertEquals(response.Status, "200 OK") 243 | Set object = {}.%FromJSON(jsonStream) 244 | Do $$$LogMessage("Response: "_object.%ToJSON()) 245 | Do $$$AssertEquals(object.%Size(),1) 246 | } 247 | 248 | Method TestBad01InvalidOrderBy() 249 | { 250 | Do $$$AssertStatusOK(..Agent.Get({"url": "person?$orderBy=-SSN"}, .jsonStream)) 251 | Set response = ..Agent.GetLastResponse() 252 | Do $$$AssertEquals(response.Status, "403 Unauthorized") 253 | Do $$$AssertEquals(response.ContentType, "application/json") 254 | Set object = {}.%FromJSON(jsonStream) 255 | Do $$$LogMessage("Response: "_object.%ToJSON()) 256 | Do $$$AssertEquals(object.errors.%Get(0).params.%Get(0), "Invalid query. Access to column 'SSN' is not permitted.") 257 | } 258 | 259 | Method TestBad02MalformedFilter() 260 | { 261 | Do $$$AssertStatusOK(..Agent.Get({"url": "person?name[foo]=Fred"}, .jsonStream)) 262 | Set response = ..Agent.GetLastResponse() 263 | Do $$$AssertEquals(response.Status, "400 Bad Request") 264 | Do $$$AssertEquals(response.ContentType, "application/json") 265 | Set object = {}.%FromJSON(jsonStream) 266 | Do $$$LogMessage("Response: "_object.%ToJSON()) 267 | Do $$$AssertEquals(object.errors.%Get(0).params.%Get(0), "Invalid query. The parameter value 'name[foo]=Fred' could not be parsed.") 268 | } 269 | 270 | Method TestBad03NoResource() 271 | { 272 | Do $$$AssertStatusOK(..Agent.Get({"url": "vehicle"}, .jsonStream)) 273 | Set response = ..Agent.GetLastResponse() 274 | Do $$$AssertEquals(response.Status, "406 Not Acceptable") 275 | Do $$$AssertEquals(response.ContentType, "application/json") 276 | 277 | Do $$$AssertStatusOK(..Agent.Get({"url": "vehicle/1"}, .jsonStream)) 278 | Set response = ..Agent.GetLastResponse() 279 | Do $$$AssertEquals(response.Status, "406 Not Acceptable") 280 | Do $$$AssertEquals(response.ContentType, "application/json") 281 | 282 | Do $$$AssertStatusOK(..Agent.Put({"url": "vehicle/1", "data":{}}, .jsonStream)) 283 | Set response = ..Agent.GetLastResponse() 284 | Do $$$AssertEquals(response.Status, "415 Unsupported Media Type") 285 | Do $$$AssertEquals(response.ContentType, "application/json") 286 | 287 | Do $$$AssertStatusOK(..Agent.Post({"url": "vehicle", "data":{}}, .jsonStream)) 288 | Set response = ..Agent.GetLastResponse() 289 | Do $$$AssertEquals(response.Status, "415 Unsupported Media Type") 290 | Do $$$AssertEquals(response.ContentType, "application/json") 291 | 292 | Do $$$AssertStatusOK(..Agent.Delete({"url": "vehicle/1"}, .jsonStream)) 293 | Set response = ..Agent.GetLastResponse() 294 | Do $$$AssertEquals(response.Status, "415 Unsupported Media Type") 295 | Do $$$AssertEquals(response.ContentType, "application/json") 296 | } 297 | 298 | Method TestBad04NoAction() 299 | { 300 | Do $$$AssertStatusOK(..Agent.Get({"url": "person/1/$promote"}, .jsonStream)) 301 | Set response = ..Agent.GetLastResponse() 302 | Do $$$AssertEquals(response.Status, "406 Not Acceptable") 303 | } 304 | 305 | Method TestBad05WrongActionMethod() 306 | { 307 | Do $$$AssertStatusOK(..Agent.Post({"url": "person/1/$update-home-address","data":{"Zip":"12345"}}, .jsonStream)) 308 | Set response = ..Agent.GetLastResponse() 309 | Do $$$AssertEquals(response.Status, "405 Method Not Allowed") 310 | } 311 | 312 | Method TestBad06MalformedJSON() 313 | { 314 | // No "data" 315 | Do $$$AssertStatusNotOK(..Agent.Put({"url": "person/1"}, .jsonStream)) 316 | Set response = ..Agent.GetLastResponse() 317 | Do $$$AssertEquals(response.Status, "400 Bad Request") 318 | } 319 | 320 | Method TestBad07NoObject() 321 | { 322 | // No person with this ID 323 | Do $$$AssertStatusNotOK(..Agent.Get({"url": "person/42000"}, .jsonStream)) 324 | Set response = ..Agent.GetLastResponse() 325 | Do $$$AssertEquals(response.Status, "404 Not Found") 326 | 327 | // For a PUT it should be 409 Conflict 328 | Do $$$AssertStatusNotOK(..Agent.Put({"url": "person/42000", "data":{}}, .jsonStream)) 329 | Set response = ..Agent.GetLastResponse() 330 | Do $$$AssertEquals(response.Status, "409 Conflict") 331 | } 332 | 333 | Method TestBad08NonJSONTypes() 334 | { 335 | Do $$$AssertStatusOK(..Agent.Get({"url": "person/1", "headers":{"Accept":"application/xml"}}, .jsonStream)) 336 | Set response = ..Agent.GetLastResponse() 337 | Do $$$AssertEquals(response.Status, "406 Not Acceptable") 338 | 339 | Do $$$AssertStatusOK(..Agent.Put({"url": "person/1/$update-home-address", "headers":{"Content-Type":"application/xml"}, "data":(##class(%Stream.FileCharacter).%New())}, .jsonStream)) 340 | Set response = ..Agent.GetLastResponse() 341 | Do $$$AssertEquals(response.Status, "415 Unsupported Media Type") 342 | 343 | Do $$$AssertStatusOK(..Agent.Put({"url": "person/1/$update-home-address", "headers":{"Accept":"application/xml"}, "data":{}}, .jsonStream)) 344 | Set response = ..Agent.GetLastResponse() 345 | Do $$$AssertEquals(response.Status, "406 Not Acceptable") 346 | } 347 | 348 | } 349 | -------------------------------------------------------------------------------- /cls/AppS/REST/Handler.cls: -------------------------------------------------------------------------------- 1 | Include %occErrors 2 | 3 | /// Base REST handler for APIs built on AppS.REST. 4 | /// Consumers should extend this, override GetUserResource and AuthenticationStrategy appropriately, and 5 | /// should *NOT* override the UrlMap XData block. 6 | Class AppS.REST.Handler Extends %CSP.REST [ System = 4 ] 7 | { 8 | 9 | /// Subclasses may override this method to provide information about the logged-in user. 10 | ///