├── .github └── workflows │ ├── main.yml │ └── objectscript-quality.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cls └── AppS │ ├── REST │ ├── ActionMap.cls │ ├── Auditor.cls │ ├── Authentication.cls │ ├── Authentication │ │ ├── PlatformBased.cls │ │ └── PlatformUser.cls │ ├── Exception │ │ ├── InvalidColumnException.cls │ │ ├── ParameterParsingException.cls │ │ └── QueryGenerationException.cls │ ├── Handler.cls │ ├── Model │ │ ├── Action │ │ │ ├── Generator.cls │ │ │ ├── Handler.cls │ │ │ ├── Projection.cls │ │ │ ├── SASchema.cls │ │ │ └── t │ │ │ │ ├── action.cls │ │ │ │ ├── actions.cls │ │ │ │ └── argument.cls │ │ ├── Adaptor.cls │ │ ├── DBMappedResource.cls │ │ ├── ISerializable.cls │ │ ├── Proxy.cls │ │ ├── QueryResult.cls │ │ ├── Resource.cls │ │ └── ResourceMapProjection.cls │ ├── QueryGenerator.cls │ └── ResourceMap.cls │ └── Util │ ├── Buffer.cls │ └── SASchemaClass.cls ├── docs ├── sample-phonebook.md └── user-guide.md ├── internal └── testing │ └── unit_tests │ ├── UnitTest │ └── AppS │ │ ├── REST │ │ ├── DriveSample.cls │ │ ├── QueryBuilder.cls │ │ ├── Sample │ │ │ ├── Data │ │ │ │ ├── Address.cls │ │ │ │ ├── Company.cls │ │ │ │ ├── Employee.cls │ │ │ │ ├── Person.cls │ │ │ │ ├── Utils.cls │ │ │ │ └── Vendor.cls │ │ │ ├── Handler.cls │ │ │ ├── Model │ │ │ │ └── Person.cls │ │ │ └── UserContext.cls │ │ ├── SamplePersistentAdapted.cls │ │ └── SampleUnprotectedClass.cls │ │ └── Util │ │ └── Buffer.cls │ └── coverage.list ├── module.xml └── samples └── phonebook ├── cls └── Sample │ └── Phonebook │ ├── Installer.cls │ ├── Model │ ├── Person.cls │ └── PhoneNumber.cls │ └── REST │ ├── Handler.cls │ └── Model │ └── PhoneNumber.cls └── module.xml /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .vscode/settings.json 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | ///
    11 | ///
  • pFullUserInfo: dynamic object with full user info provided by the authentication strategy 12 | ///
      13 | ClassMethod GetUserResource(pFullUserInfo As %DynamicObject) As AppS.REST.Model.ISerializable 14 | { 15 | Quit $$$NULLOREF 16 | } 17 | 18 | /// Specifies the default character set for the page. This can be overriden using the 19 | /// <CSP:CONTENT CHARSET=> tag, or by setting the %response.CharSet property 20 | /// in the OnPreHTTP method. If this parameter is not specified, then 21 | /// for the default charset is utf-8. 22 | Parameter CHARSET = "utf-8"; 23 | 24 | /// Specifies if input %request.Content or %request.MimeData values are converted from their 25 | /// original character set on input. By default (0) we do not modify these and receive them 26 | /// as a binary stream which may need to be converted manually later. If 1 then if there 27 | /// is a 'charset' value in the request Content-Type or mime section we will convert from this 28 | /// charset when the input data is text based. For either json or xml data with no charset 29 | /// this will convert from utf-8 or honor the BOM if one is present. 30 | Parameter CONVERTINPUTSTREAM = 1; 31 | 32 | XData UrlMap [ XMLNamespace = "http://www.intersystems.com/urlmap" ] 33 | { 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | } 62 | 63 | /// Subclasses must override this to define a custom authentication strategy class. 64 | ClassMethod AuthenticationStrategy() As %Dictionary.CacheClassname [ Abstract ] 65 | { 66 | } 67 | 68 | /// This method Gets called prior to dispatch of the request. Put any common code here 69 | /// that you want to be executed for EVERY request. If pContinue is set to 0, the 70 | /// request will NOT be dispatched according to the UrlMap. If this case it's the 71 | /// responsibility of the user to return a response. 72 | ClassMethod OnPreDispatch(pUrl As %String, pMethod As %String, ByRef pContinue As %Boolean) As %Status 73 | { 74 | #dim %response As %CSP.Response 75 | Set sc = $$$OK 76 | If pMethod '= "OPTIONS" { // OPTIONS requests are never authenticated 77 | Set sc = $classmethod(..AuthenticationStrategy(),"Authenticate",pUrl,.pContinue) 78 | } 79 | Do:'pContinue ..OnHandleCorsRequest(pUrl) 80 | Quit sc 81 | } 82 | 83 | ClassMethod GetUserInfo() As %Status 84 | { 85 | #dim %response As %CSP.Response 86 | Set userContext = ..GetUserContext() 87 | If $IsObject(userContext) { 88 | Set %response.ContentType = userContext.#MEDIATYPE 89 | $$$ThrowOnError(userContext.JSONExport()) 90 | } Else { 91 | Set %response.Status = ..#HTTP204NOCONTENT 92 | } 93 | Quit $$$OK 94 | } 95 | 96 | ClassMethod GetUserContext() As AppS.REST.Model.Resource 97 | { 98 | $$$ThrowOnError($classmethod(..AuthenticationStrategy(),"UserInfo",.userInfo)) 99 | Quit ..GetUserResource(.userInfo) 100 | } 101 | 102 | ClassMethod LogOut() As %Status 103 | { 104 | Quit $classmethod(..AuthenticationStrategy(),"Logout") 105 | } 106 | 107 | /// Creates a new instance of the resource (handling a POST request to the resource's endpoint) 108 | ClassMethod Create(resource As %String) As %Status 109 | { 110 | #dim %request As %CSP.Request 111 | #dim %response As %CSP.Response 112 | 113 | // Grab the json body from the incoming reqeust 114 | Set json = {}.%FromJSON(%request.Content) 115 | 116 | // Get proxy class based on the request's content type header and the resource 117 | Set resourceClass = ..FindContentClass(resource, .tSkip) 118 | If tSkip { 119 | Return $$$OK 120 | } 121 | 122 | Set userContext = ..GetUserContext() 123 | If '$classmethod(resourceClass, "CheckPermission", "", "CREATE", userContext) { 124 | Do ..ReportHttpStatusCode(..#HTTP403FORBIDDEN, $$$ERROR($$$GeneralError,"Access denied: class "_resourceClass_", CREATE")) 125 | Return $$$OK 126 | } 127 | 128 | // Instantiate a proxy without passing an id; this will give us an empty one 129 | Set resourceToUse = $classmethod(resourceClass, "GetModelInstance") 130 | $$$ThrowOnError(resourceToUse.JSONImport(json)) 131 | Do resourceToUse.SaveModelInstance(userContext) 132 | 133 | // Respond with a json block representing the newly posted resource 134 | Set %response.ContentType = $parameter(resourceClass, "MEDIATYPE") 135 | Do resourceToUse.JSONExport() 136 | 137 | return $$$OK 138 | } 139 | 140 | ClassMethod CollectionQuery(resource As %String) As %Status 141 | { 142 | // Grab the parameters that define the filters for the query 143 | // These come in as URL parameters via the request 144 | Kill params 145 | Merge params = %request.Data 146 | 147 | // Use the request's content type and resource name to determine which proxy class to use 148 | Set resourceClass = ..FindAcceptedClass(resource, .tSkip) 149 | If tSkip { 150 | Return 1 151 | } 152 | 153 | Set userContext = ..GetUserContext() 154 | If '$classmethod(resourceClass, "CheckPermission", "", "QUERY", userContext) { 155 | Do ..ReportHttpStatusCode(..#HTTP403FORBIDDEN, $$$ERROR($$$GeneralError,"Access denied: class "_resourceClass_", QUERY")) 156 | Return 1 157 | } 158 | 159 | Set %response.ContentType = $parameter(resourceClass, "MEDIATYPE") 160 | 161 | Try { 162 | Do $classmethod(resourceClass, "GetCollection", .params) 163 | } Catch e { 164 | // For well-defined exception types, report appropriately. 165 | If e.%IsA("AppS.REST.Exception.QueryGenerationException") { 166 | Do ..ReportHttpStatusCode(e.ErrorStatus, $$$ERROR($$$GeneralError,e.DisplayString())) 167 | Quit 168 | } 169 | // Otherwise, re-throw. 170 | Throw e 171 | } 172 | 173 | Return 1 174 | } 175 | 176 | ClassMethod Retrieve(resourceName As %String, id As %String) As %Status 177 | { 178 | #dim %response As %CSP.Response 179 | #dim resource As AppS.REST.Model.Resource 180 | 181 | Set resourceClass = ..FindAcceptedClass(resourceName, .tSkip) 182 | If tSkip { 183 | Return 1 184 | } 185 | 186 | If '$classmethod(resourceClass, "CheckPermission", id, "READ", ..GetUserContext()) { 187 | Do ..ReportHttpStatusCode(..#HTTP403FORBIDDEN, $$$ERROR($$$GeneralError,"Access denied: class "_resourceClass_", ID: "_id_", READ")) 188 | Return 1 189 | } 190 | 191 | Set %response.ContentType = $parameter(resourceClass, "MEDIATYPE") 192 | Set resource = $classmethod(resourceClass, "GetModelInstance", id) 193 | Quit resource.JSONExport() 194 | } 195 | 196 | ClassMethod Construct(resourceName As %String) As %Status 197 | { 198 | #dim %response As %CSP.Response 199 | #dim resource As AppS.REST.Model.Resource 200 | 201 | Set resourceClass = ..FindAcceptedClass(resourceName, .tSkip) 202 | If tSkip { 203 | Return 1 204 | } 205 | 206 | // READ with no ID, or ACTION:new, is usable as a permission for this special case. 207 | If '($classmethod(resourceClass, "CheckPermission", "", "READ", ..GetUserContext()) 208 | || $classmethod(resourceClass, "CheckPermission", "", "ACTION:new", ..GetUserContext())) { 209 | Do ..ReportHttpStatusCode(..#HTTP403FORBIDDEN, $$$ERROR($$$GeneralError,"Access denied: class "_resourceClass_", ACTION:new")) 210 | Return 1 211 | } 212 | 213 | Set %response.ContentType = $parameter(resourceClass,"MEDIATYPE") 214 | Set resource = $classmethod(resourceClass, "GetModelInstance") 215 | Quit resource.JSONExport() 216 | } 217 | 218 | ClassMethod Update(resourceName As %String, id As %String) As %Status 219 | { 220 | #dim resourceToUse As AppS.REST.Model.Resource 221 | 222 | // Grab the json body from the incoming reqeust 223 | Set json = {}.%FromJSON(%request.Content) 224 | 225 | // Get proxy class based on the request's content type header and the resource 226 | Set resourceClass = ..FindContentClass(resourceName, .tSkip) 227 | If tSkip { 228 | Return $$$OK 229 | } 230 | 231 | Set userContext = ..GetUserContext() 232 | If '$classmethod(resourceClass, "CheckPermission", id, "UPDATE", userContext) { 233 | Do ..ReportHttpStatusCode(..#HTTP403FORBIDDEN, $$$ERROR($$$GeneralError,"Access denied: class "_resourceClass_", ID "_id_", UPDATE")) 234 | Return $$$OK 235 | } 236 | 237 | Set resourceToUse = $classmethod(resourceClass, "GetModelInstance", id) 238 | $$$ThrowOnError(resourceToUse.JSONImport(json)) 239 | Do resourceToUse.SaveModelInstance(userContext) 240 | 241 | Set %response.ContentType = $parameter(resourceClass,"MEDIATYPE") 242 | Do resourceToUse.JSONExport() 243 | Return $$$OK 244 | } 245 | 246 | ClassMethod Delete(resourceName As %String, id As %String) As %Status 247 | { 248 | #dim %response As %CSP.Response 249 | #dim resource As AppS.REST.Model.Resource 250 | 251 | Set resourceClass = ..FindContentClass(resourceName, .tSkip) 252 | If tSkip { 253 | Return 1 254 | } 255 | 256 | If '$classmethod(resourceClass, "CheckPermission", id, "DELETE", ..GetUserContext()) { 257 | Set %response.Status = ..#HTTP403FORBIDDEN 258 | Return 1 259 | } 260 | 261 | Set deleted = $classmethod(resourceClass, "DeleteModelInstance", id) 262 | If deleted { 263 | Set %response.Status = ..#HTTP204NOCONTENT 264 | } Else { 265 | Set %response.Status = ..#HTTP404NOTFOUND 266 | } 267 | Quit $$$OK 268 | } 269 | 270 | ClassMethod DispatchClassAction(resourceName As %String, action As %String) As %Status 271 | { 272 | #dim %response As %CSP.Response 273 | #dim resource As AppS.REST.Model.Resource 274 | 275 | Set actionClass = ..FindActionClass(resourceName, action, "class", .tSkip, .resourceClass) 276 | If tSkip { 277 | Return 1 278 | } 279 | 280 | If '$classmethod(resourceClass, "CheckPermission", "", "ACTION:"_action, ..GetUserContext()) { 281 | Do ..ReportHttpStatusCode(..#HTTP403FORBIDDEN, $$$ERROR($$$GeneralError,"Access denied: class "_resourceClass_", ACTION:"_action)) 282 | Return 1 283 | } 284 | 285 | Do $classmethod(actionClass,"HandleInvokeClassAction", %request.Method, action, ..GetUserContext()) 286 | 287 | Quit $$$OK 288 | } 289 | 290 | ClassMethod DispatchInstanceAction(resourceName As %String, id As %String, action As %String) As %Status 291 | { 292 | #dim %response As %CSP.Response 293 | #dim resource As AppS.REST.Model.Resource 294 | 295 | Set actionClass = ..FindActionClass(resourceName, action, "instance", .tSkip, .resourceClass) 296 | If tSkip { 297 | Return 1 298 | } 299 | 300 | If '$classmethod(resourceClass, "CheckPermission", id, "ACTION:"_action, ..GetUserContext()) { 301 | Do ..ReportHttpStatusCode(..#HTTP403FORBIDDEN, $$$ERROR($$$GeneralError,"Access denied: class "_resourceClass_", ID "_id_", ACTION:"_action)) 302 | Return 1 303 | } 304 | 305 | Set resourceInstance = $classmethod(resourceClass, "GetModelInstance", id) 306 | Do $classmethod(actionClass,"HandleInvokeInstanceAction", %request.Method, resourceInstance, action, ..GetUserContext()) 307 | 308 | Quit $$$OK 309 | } 310 | 311 | ClassMethod FindActionClass(pResource As %String, pAction As %String, pTarget As %String, Output pSkipProcessing As %Boolean = 0, Output pResourceClass As %Dictionary.CacheClassname) As %Dictionary.CacheClassname [ Private ] 312 | { 313 | #dim %response As %CSP.Response 314 | #dim %request As %CSP.Request 315 | 316 | Set accepts = $Piece(%request.GetCgiEnv("HTTP_ACCEPT"),";") 317 | If (accepts '= "") && (accepts '= "*/*") { 318 | If '$Match(accepts,"application/(.*\+)?json") { 319 | Set pSkipProcessing = 1 320 | Do ..ReportHttpStatusCode(..#HTTP406NOTACCEPTABLE, $$$ERROR($$$GeneralError,"Only JSON-based media types are supported.")) 321 | Quit "" 322 | } 323 | } Else { 324 | Set accepts = $c(0) 325 | } 326 | 327 | Set mediaType = $Piece(%request.ContentType,";") 328 | If (mediaType '= "") { 329 | If '$Match(mediaType,"application/(.*\+)?json") { 330 | Set pSkipProcessing = 1 331 | Do ..ReportHttpStatusCode(..#HTTP415UNSUPPORTEDMEDIATYPE, $$$ERROR($$$GeneralError,"Only JSON-based media types are supported.")) 332 | Quit "" 333 | } 334 | } Else { 335 | Set mediaType = $c(0) 336 | } 337 | 338 | // Try mediaType = $c(0) as well as the specified media type if the request content is empty 339 | For tryMediaType = mediaType,$c(0) { 340 | If (tryMediaType = $c(0)) && %request.Content.Size { 341 | // Per HTTP spec, this is the default. 342 | Set tryMediaType = "application/octet-stream" 343 | } 344 | Set sc = $$$OK 345 | /// (ResourceName, ActionName, ActionTarget, HTTPVerb, MediaTypeOrNUL, AcceptsOrNUL) 346 | Set map = ##class(AppS.REST.ActionMap).UniqueByRequestOpen(pResource,pAction,pTarget,%request.Method,accepts,tryMediaType,,.sc) 347 | If $$$ISOK(sc) { 348 | Quit 349 | } 350 | } 351 | If $System.Status.Equals(sc,$$$KeyValueNotFoundOpen) { 352 | // See if method is the only thing that's wrong: 353 | Set methodFound = 0 354 | For otherMethod = "PUT","POST","GET","DELETE" { 355 | If ##class(AppS.REST.ActionMap).UniqueByRequestExists(pResource,pAction,pTarget,otherMethod,accepts,mediaType) { 356 | Set methodFound = 1 357 | Do ..ReportHttpStatusCode(..#HTTP405METHODNOTALLOWED) 358 | Quit 359 | } 360 | } 361 | 362 | If 'methodFound { 363 | // Naive approach: complain about HTTP_ACCEPT if media type is empty, media type if not. 364 | Do ..ReportHttpStatusCode($Case(mediaType,$c(0):..#HTTP415UNSUPPORTEDMEDIATYPE,:..#HTTP406NOTACCEPTABLE)) 365 | // TODO: Descriptive info about media types available for the specified resource/action 366 | } 367 | Set pSkipProcessing = 1 368 | Quit "" 369 | } 370 | $$$ThrowOnError(sc) 371 | 372 | Set pResourceClass = map.ModelClass 373 | Quit map.ImplementationClass 374 | } 375 | 376 | ClassMethod FindAcceptedClass(pResource As %String, Output pSkipProcessing As %Boolean = 0) As %Dictionary.CacheClassname [ Private ] 377 | { 378 | #dim %request As %CSP.Request 379 | Quit ..FindClass(%request.GetCgiEnv("HTTP_ACCEPT"),pResource,.pSkipProcessing,..#HTTP406NOTACCEPTABLE) 380 | } 381 | 382 | ClassMethod FindContentClass(pResource As %String, Output pSkipProcessing As %Boolean = 0) As %Dictionary.CacheClassname [ Private ] 383 | { 384 | #dim %request As %CSP.Request 385 | Quit ..FindClass(%request.ContentType,pResource,.pSkipProcessing,..#HTTP415UNSUPPORTEDMEDIATYPE) 386 | } 387 | 388 | ClassMethod FindClass(pMediaType As %String, pResource As %String, Output pSkipProcessing As %Boolean = 0, pStatusWhenInvalid As %String) As %Dictionary.CacheClassname [ Private ] 389 | { 390 | #Dim %response As %CSP.Response 391 | Set pMediaType = $Piece(pMediaType,";") 392 | If '$Match(pMediaType,"application/(.*\+)?json") { 393 | Set pSkipProcessing = 1 394 | Do ..ReportHttpStatusCode(pStatusWhenInvalid, $$$ERROR($$$GeneralError,"Only JSON is supported.")) 395 | Quit "" 396 | } 397 | 398 | Set map = ##class(AppS.REST.ResourceMap).IDKEYOpen(pResource,pMediaType,,.sc) 399 | If $System.Status.Equals(sc,$$$LoadObjectNotFound) { 400 | Do ..ReportHttpStatusCode(pStatusWhenInvalid, sc) 401 | Set pSkipProcessing = 1 402 | Quit "" 403 | } 404 | $$$ThrowOnError(sc) 405 | 406 | Quit map.ModelClass 407 | } 408 | 409 | /// Subclasses may override to customize logging.
      410 | /// To suppress error logging, set ^Config("AppS","REST","SuppressLogging") = 1 411 | ClassMethod LogErrorStatus(pStatus As %Status) 412 | { 413 | If '$Get(^Config("AppS","REST","SuppressLogging"),0) { 414 | Set e = ##class(%Exception.StatusException).CreateFromStatus(pStatus) 415 | Do e.Log() 416 | } 417 | } 418 | 419 | /// Issue an 'Http' error 420 | ClassMethod ReportHttpStatusCode(pHttpStatus, pSC As %Status = {$$$OK}) As %Status 421 | { 422 | #dim %request As %CSP.Request 423 | If $$$ISERR(pSC) { 424 | Do ..LogErrorStatus(pSC) 425 | } 426 | 427 | // If a default HTTP status is supplied, try to be more descriptive. 428 | If (pHttpStatus = ..#HTTP500INTERNALSERVERERROR) { 429 | // Special cases for pSC: 430 | If $System.Status.Equals(pSC,$$$AccessDenied) { 431 | // $$$AccessDenied -> 403 Forbidden 432 | Set %response.Status = ..#HTTP403FORBIDDEN 433 | Quit $$$OK 434 | } ElseIf $System.Status.Equals(pSC,$$$GeneralException) { 435 | // JSON parsing exception 436 | Set %response.Status = ..#HTTP400BADREQUEST 437 | Quit $$$OK 438 | } ElseIf $System.Status.Equals(pSC,$$$LoadObjectNotFound) 439 | || $System.Status.Equals(pSC,$$$KeyValueNotFoundOpen) { 440 | // $$$LoadObjectNotFound -> 404 (GET) or 409 (PUT) 441 | If (%request.Method = "GET") { 442 | Quit ##super(..#HTTP404NOTFOUND,pSC) 443 | } Else /* PUT */ { 444 | Quit ##super(..#HTTP409CONFLICT,pSC) 445 | } 446 | } 447 | } ElseIf (pHttpStatus = ..#HTTP403FORBIDDEN) && $$$ISERR(pSC) { 448 | // Don't be too descriptive, though full details of the violation are logged. 449 | Set %response.Status = ..#HTTP403FORBIDDEN 450 | Quit $$$OK 451 | } 452 | Quit ##super(pHttpStatus,pSC) 453 | } 454 | 455 | /// Overridden to use StatusToJSON() for proper escaping 456 | ClassMethod outputStatus(pSC As %Status) As %Status [ Internal ] 457 | { 458 | #dim tSC As %Status = $$$OK 459 | #dim e As %Exception.AbstractException 460 | 461 | Try { 462 | #dim tJSON As %ZEN.proxyObject 463 | If ..AcceptsContentType("application/json") { 464 | Set %response.ContentType = ..#CONTENTTYPEJSON 465 | // Convert the exception to a status and render to JSON 466 | Set tSC = ..StatusToJSON(pSC, .tJSON) 467 | If $$$ISERR(tSC) Quit 468 | // Write the JSON to the output device 469 | Write tJSON.%ToJSON() 470 | } else { 471 | // Set plain text 472 | Set %response.ContentType = ..#CONTENTTYPETEXT 473 | // Write out a simple text message 474 | Do ##class(%Exception.StatusException).CreateFromStatus(pSC).OutputToDevice() 475 | } 476 | } Catch (e) { 477 | Set tSC = e.AsStatus() 478 | } 479 | Quit $$$OK 480 | } 481 | 482 | } 483 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/sample-phonebook.md: -------------------------------------------------------------------------------- 1 | # AppS.REST Tutorial and Sample Application: Contact List 2 | 3 | This document describes how to build a sample application with AppS.REST using a list of contacts and phone numbers as a motivating example. 4 | 5 | ## Installing the Sample 6 | 7 | The final version of the sample described here is in /samples/phonebook under the repository root. To install this sample using the [Community Package Manager](https://github.com/intersystems-community/zpm), clone the apps-rest repo, note the path to /samples/phonebook on your local filesystem, then run via IRIS terminal / iris session: 8 | 9 | ```bash 10 | zpm "load -dev -verbose /path/to/samples/phonebook" 11 | ``` 12 | 13 | This automatically configures a REST-enabled web application with the sample dispatch class and password authentication enabled. It also sets up some sample data. 14 | 15 | ## The Contact List Data Model 16 | 17 | Suppose as a starting point the following data model in two simple ObjectScript classes, with storage definitions omitted for simplicity: 18 | 19 | ```ObjectScript 20 | Class Sample.Phonebook.Model.Person Extends %Persistent 21 | { 22 | 23 | Property Name As %String; 24 | 25 | Relationship PhoneNumbers As Sample.Phonebook.Model.PhoneNumber [ Cardinality = children, Inverse = Person ]; 26 | 27 | } 28 | 29 | Class Sample.Phonebook.Model.PhoneNumber Extends %Persistent 30 | { 31 | 32 | Relationship Person As Sample.Phonebook.Model.Person [ Cardinality = parent, Inverse = PhoneNumbers ]; 33 | 34 | Property PhoneNumber As %String; 35 | 36 | Property Type As %String(VALUELIST = ",Mobile,Home,Office"); 37 | 38 | } 39 | ``` 40 | 41 | That is: a person has a name and some number of phone numbers (which aren't much use independent of the related contact - hence a parent-child relationship). Each phone number has a type - either Mobile, Home, or Office. 42 | 43 | We want to enable the following behavior against this data model via REST: 44 | 45 | * A user should be able to list all contacts and their phone numbers. 46 | * A user should be able to create a new contact or update a contact's name. 47 | * A user should be able to add, remove, and update phone numbers for a contact. 48 | * A user should be able to search by a string and find all contacts whose phone numbers contain that string (along with their phone numbers). 49 | 50 | ## Defining our REST Handler 51 | 52 | The starting point for any REST API in InterSystems IRIS Data Platform is a "dispatch class" - a subclass of `%CSP.REST` that defines all of the available endpoints and associates them to their behavior. When using AppS.REST, this class instead extends `AppS.REST.Handler`. The simplest possible such class, assuming a REST API protected by IRIS password authentication, is: 53 | 54 | ```ObjectScript 55 | Class Sample.Phonebook.REST.Handler Extends AppS.REST.Handler 56 | { 57 | 58 | ClassMethod AuthenticationStrategy() As %Dictionary.CacheClassname 59 | { 60 | Quit ##class(AppS.REST.Authentication.PlatformBased).%ClassName(1) 61 | } 62 | 63 | ClassMethod GetUserResource(pFullUserInfo As %DynamicObject) As AppS.REST.Authentication.PlatformUser 64 | { 65 | Quit ##class(AppS.REST.Authentication.PlatformUser).%New() 66 | } 67 | 68 | } 69 | ``` 70 | 71 | `AppS.REST.Authentication.PlatformUser` is just an object wrapper around `$Username` - an application with a more complex concept of the current user might extend this class and add more properties, such as the user's name or any application-specific user characteristics. An authentication strategy `AppS.REST.Authentication.PlatformBased` indicates that platform-level authentication options such as IRIS Password or Delegated authentication are used. 72 | 73 | ### Automating REST Application Configuration 74 | 75 | It's easy to set up a REST application via the Management Portal > System Administration > Security > Web Applications, as described in the [IRIS documentation](https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=GREST_intro). If you're using the ObjectScript package manager, it's even easier - just add a CSPApplication element in your module.xml. For example [(full context here)](../samples/phonebook/module.xml): 76 | 77 | ```xml 78 | 87 | ``` 88 | 89 | This creates a new web application, /csp/(namespace in which the module is installed)/phonebook-sample/api, with `Sample.Phonebook.REST.Handler` as its dispatch class. Throughout this demo we'll assume the USER namespace. 90 | 91 | ## JSON-enabling the Data Model 92 | 93 | The first step in REST-enabling the data model is to extend `%JSON.Adaptor` in all relevant registered/persistent classes. For projection as JSON, our PascalCase property names look a little strange, and we can use `%JSON.Adaptor` features (property parameters) to make them better. 94 | 95 | The JSON projection via %JSON.Adaptor doesn't include the Row ID, and that's handy to have, so we'll add it via some transient, calculated properties. 96 | 97 | At this stage, the classes will look like: 98 | 99 | ```ObjectScript 100 | Class Sample.Phonebook.Model.Person Extends (%Persistent, %JSON.Adaptor) 101 | { 102 | 103 | Parameter RESOURCENAME = "contact"; 104 | 105 | Property RowID As %String(%JSONFIELDNAME = "_id", %JSONINCLUDE = "outputonly") [ Calculated, SqlComputeCode = {Set {*} = {%%ID}}, SqlComputed, Transient ]; 106 | 107 | Property Name As %String(%JSONFIELDNAME = "name"); 108 | 109 | Relationship PhoneNumbers As Sample.Phonebook.Model.PhoneNumber(%JSONFIELDNAME = "phones", %JSONINCLUDE="outputonly", %JSONREFERENCE = "object") [ Cardinality = children, Inverse = Person ]; 110 | 111 | } 112 | 113 | 114 | Class Sample.Phonebook.Model.PhoneNumber Extends (%Persistent, %JSON.Adaptor) 115 | { 116 | 117 | Relationship Person As Sample.Phonebook.Model.Person(%JSONINCLUDE = "none") [ Cardinality = parent, Inverse = PhoneNumbers ]; 118 | 119 | Property RowID As %String(%JSONFIELDNAME = "_id", %JSONINCLUDE = "outputonly") [ Calculated, SqlComputeCode = {Set {*} = {%%ID}}, SqlComputed, Transient ]; 120 | 121 | Property PhoneNumber As %String(%JSONFIELDNAME = "number"); 122 | 123 | Property Type As %String(%JSONFIELDNAME = "type", VALUELIST = ",Mobile,Home,Office"); 124 | 125 | } 126 | ``` 127 | 128 | The `%JSONFIELDNAME` value overrides the name of the property when projected to and from JSON. For contacts, `RowID` becomes `_id`, `Name` becomes `name`, and `PhoneNumbers` becomes `phones`. For phone numbers, `PhoneNumber` becomes `number` in JSON inputs and outputs, `Type` becomes `type`, and `RowID` becomes `_id`. 129 | 130 | The `%JSONINCLUDE` value specifies how the property will be handled when projecting to and from JSON. `Name`, `PhoneNumbers`, `PhoneNumber` and `Type` have no `%JSONINCLUDE`, so they are projected normally. In the case of `RowID`, the value is "outputonly", meaning it can't be changed. In the case of `Person`, which is a relationship, we won't allow editing via the top-level object so we specify a value of "none". 131 | 132 | Putting it all together, an instance of Person with one PhoneNumber will look like this when projected to JSON: 133 | 134 | ```JSON 135 | { 136 | "_id": "1", 137 | "name": "Semmens,Valery X.", 138 | "phones": [{ 139 | "_id": "1||199", 140 | "number": "965-226-3942", 141 | "type": "Home" 142 | }] 143 | } 144 | ``` 145 | 146 | ## REST-enabling the Contact Listing 147 | 148 | Before getting started with REST, it's handy to have a REST client. There are lots of these out there - [Postman](https://www.postman.com/) and [Advanced REST Client](https://install.advancedrestclient.com/install) are perhaps some of the more well-known. 149 | 150 | > Note: You can't just paste requests into your web browser because you need to set "Accepts" HTTP header to "application/json" before sending a request. 151 | 152 | We have a data model that defines how to store data in the database, and how to project it into JSON format. Now we need to expose it via a REST API. There are 3 steps for each class that is REST-enabled: 153 | 154 | 1. Extend `AppS.REST.Model.Adaptor` 155 | 2. Define its REST endpoint via the `RESOURCENAME` parameter 156 | 3. Set permissions for the endpoint 157 | 158 | ### Extend `AppS.REST.Model.Adaptor` 159 | 160 | To REST-enable the Person class to allow listing all of the people, first extend `AppS.REST.Model.Adaptor`: 161 | 162 | ```ObjectScript 163 | Class Sample.Phonebook.Model.Person Extends (%Persistent, %Populate, %JSON.Adaptor, AppS.REST.Model.Adaptor) 164 | ``` 165 | 166 | ### Define the REST endpoint 167 | 168 | Next, override the `RESOURCENAME` parameter, specifying a name that, together with the base URL, will become the REST endpoint for the resource. 169 | 170 | ```ObjectScript 171 | Parameter RESOURCENAME = "contact"; 172 | ``` 173 | 174 | ### Set permissions for the endpoint 175 | 176 | Add a `CheckPermission` method to the class. For `Sample.Phonebook.REST.Model.PhoneNumber` we will only allow the QUERY operation. 177 | 178 | `CheckPermission` takes the following input parameters: 179 | 180 | * pID: an instance of String... 181 | * pOperation: an instance of String... 182 | * pUserContext: an instance of `AppS.REST.Authentication.PlatformUser` (the type returned by the `GetUserResource` method in `Sample.Phonebook.REST.Handler` - you can override this in the method in your model class (instead of leaving it as the default %RegisteredObject) to make your IDE more helpful. 183 | 184 | ```ObjectScript 185 | /// Checks the user's permission for a particular operation on a particular record. 186 | /// pOperation may be one of: 187 | /// CREATE 188 | /// READ 189 | /// UPDATE 190 | /// DELETE 191 | /// QUERY 192 | /// ACTION: 193 | /// pUserContext is supplied by GetUserContext 194 | ClassMethod CheckPermission(pID As %String, pOperation As %String, pUserContext As AppS.REST.Authentication.PlatformUser) As %Boolean 195 | { 196 | Quit (pOperation = "QUERY") 197 | } 198 | ``` 199 | 200 | Using your REST client (and the appropriate web server port for your IRIS instance), you can now make a GET request to /csp/user/phonebook-sample/api/contact to retrieve the full list of contacts. 201 | 202 | > Important: Be sure to set "Accepts" header to "application/json" before sending the request. 203 | 204 | ## REST-enabling CRUD Operations 205 | 206 | What about allowing *update* of contact names? From a coding perspective, all you need to do to allow contact creation and updates is to allow the CREATE and UPDATE actions. While we're at it, let's allow the READ operation as well. `CheckPermission` now looks like this: 207 | 208 | ```ObjectScript 209 | ClassMethod CheckPermission(pID As %String, pOperation As %String, pUserContext As AppS.REST.Authentication.PlatformUser) As %Boolean 210 | { 211 | Quit (pOperation = "QUERY") || (pOperation = "READ") || (pOperation = "CREATE") || (pOperation = "UPDATE") 212 | } 213 | ``` 214 | 215 | Remember that the `%JSONINCLUDE` property parameters on `Sample.Phonebook.Model.Person` are set to `"outputonly"` on all but the `Name` property. In other words, if you specify `_id` and `phones` in JSON and pass it to `%JSONImport()` on an instance of `Sample.Phonebook.Model.Person`, those properties will just be ignored. 216 | 217 | This is a feature - and it provides for security within the REST tooling provided by the Apps.REST framework. It is important to think about security in this way up-front, to make sure that there is no exposure for modification of data outside of the desired scope. 218 | 219 | Our REST model is ready to accept updates. 220 | 221 | ### Try out the CRUD operations 222 | 223 | From your REST client, try the following: 224 | 225 | * Set the "Accept" header to "application/json" 226 | * Set the "Content-Type" header to "application/json" 227 | * `POST` a JSON body of `{"name":"Flintstone,Fred"}` to /csp/user/phonebook-sample/api/contact 228 | * `PUT` a JSON body of `{"name":"Rubble,Barney"}` to /csp/user/phonebook-sample/api/contact/1 229 | * `GET` /csp/user/phonebook-sample/api/contact/1 - you should see the result of the change you just made. 230 | 231 | ## REST-enabling Phone Number Operations 232 | 233 | Extending `AppS.REST.Model.Adaptor` like we did on `Sample.Phonebook.Model.Person` is one of two ways to REST-enable access to data; it operates by inheritance (that is, you extend it to enable REST access to the class that extends it). 234 | 235 | The other approach is to use `AppS.REST.Model.Proxy`. A Proxy implementation stands separately from the class of data being accessed. This is necessary if you need to provide multiple representations of the same data, and also may be preferable if you want to keep the REST aspects of permissions, actions, etc. separate from the persistent class. `RESOURCENAME` and `CheckPermission` are overridden as before, but the `SOURCECLASS` parameter must also be specified, pointing to a JSON-enabled persistent class. For example, to enable creation, update and deletion of phone numbers without making any further changes to `Sample.Phonebook.Model.PhoneNumber`, a proxy may be defined as follows: 236 | 237 | ```ObjectScript 238 | Class Sample.Phonebook.REST.Model.PhoneNumber Extends AppS.REST.Model.Proxy 239 | { 240 | 241 | Parameter RESOURCENAME = "phone-number"; 242 | 243 | Parameter SOURCECLASS = "Sample.Phonebook.Model.PhoneNumber"; 244 | 245 | ClassMethod CheckPermission(pID As %String, pOperation As %String, pUserContext As AppS.REST.Authentication.PlatformUser) As %Boolean 246 | { 247 | Quit (pOperation = "UPDATE") || (pOperation = "DELETE") 248 | } 249 | 250 | } 251 | ``` 252 | 253 | For example, this will allow a `PUT` of `{"number":"123-456-7890","type":"Office"}` to /csp/user/phonebook-sample/api/phone-number/1||199 (assuming 1||199 is a valid PhoneNumber ID), or a `DELETE` of that same URI. 254 | 255 | Adding a new phone number is more complicated, though; the REST projection for phone numbers has `%JSONINCLUDE="none"` on the related `Person`. This is necessary to avoid infinite loops trying to project the set of objects to JSON for the main listing. There are two different approaches to solving this problem in the AppS.REST framework: "actions" and alternative JSON mappings. 256 | 257 | ### Adding a Phone Number via an Alternative JSON Mapping 258 | 259 | `%JSON.Adaptor` supports creation of multiple JSON mappings, and AppS.REST can use this feature to handle multiple representations of the same resource. To start out, create an XData block in `Sample.Phonebook.Model.PhoneNumber` as follows: 260 | 261 | ```xml 262 | XData PhoneNumberWithPerson [ XMLNamespace = "http://www.intersystems.com/jsonmapping" ] 263 | { 264 | 265 | 266 | 267 | 268 | 269 | 270 | } 271 | ``` 272 | 273 | The attribute names map to the property parameter names noted previously. The field names are the same as the basic mapping, with the addition of a "person" field mapping to the ID of the referenced person. 274 | 275 | With this in place, and updating `CheckPermission` to also allow the `"CREATE"` operation, a JSON body like `{"number":"123-456-7890","type":"Office","person":1}` can be posted to /csp/user/phonebook-sample/api/phone-number to add a new phone number. 276 | 277 | ### Adding a Phone Number via an Action 278 | 279 | Suppose a multi-tenant environment where each person only has access to a subset of contacts. In such a case, a user should not be allowed to update or delete phone numbers associated with another person's contacts. This is enforceable in `CheckPermissions` on the phone number model. But when adding a new contact, the validity of the data would depend on the JSON payload. While such checking is possible through more complicated mechanisms outside the scope of this tutorial, doing security checks there decentralizes the security checking and opens up the possibility of vulnerabilities. 280 | 281 | Instead of viewing `"CREATE"` of a phone number for a contact as an action on the phone-number resource, it could be reimagined as an action that is taken on the contact. Security checking could live alongside that of the contact, and would be the same as for other operations on that contact. (The same could also apply for other operations on phone numbers.) 282 | 283 | First, we'll create an instance method in `Sample.Phonebook.Model.Person` that takes an instance of `Sample.Phonebook.Model.PhoneNumber`, sets the Person for that phone number to the current instance, saves the phone number, and returns the current Person instance. This is very simple with ObjectScript: 284 | 285 | ```ObjectScript 286 | Method AddPhoneNumber(phoneNumber As Sample.Phonebook.Model.PhoneNumber) As Sample.Phonebook.Model.Person 287 | { 288 | Set phoneNumber.Person = $This 289 | $$$ThrowOnError(phoneNumber.%Save()) 290 | Quit $This 291 | } 292 | ``` 293 | 294 | Next, we'll define a new XData block called "ActionMap" in `Sample.Phonebook.Model.Person`, as follows: 295 | 296 | ```xml 297 | XData ActionMap [ XMLNamespace = "http://www.intersystems.com/apps/rest/action" ] 298 | { 299 | 300 | 301 | 302 | 303 | 304 | } 305 | ``` 306 | 307 | This says that a POST request to /contact/(contact ID)/$add-phone will call the AddPhoneNumber of that instance, providing the automatically-deserialized phoneNumber object from the body (based on the argument type in the method signature) and responding with a JSON export of the updated instance of `Sample.Phonebook.Model.Person` (based on the return type in the method signature). 308 | 309 | Now that the "add-phone" action has been defined, we must also enable access to it in CheckPermission: 310 | 311 | ```ObjectScript 312 | ClassMethod CheckPermission(pID As %String, pOperation As %String, pUserContext As AppS.REST.Authentication.PlatformUser) As %Boolean 313 | { 314 | Quit (pOperation = "QUERY") || (pOperation = "READ") || (pOperation = "CREATE") || (pOperation = "UPDATE") || 315 | (pOperation = "ACTION:add-phone") 316 | } 317 | ``` 318 | 319 | With all of this in place, a POST of `{"number":"123-456-7890","type":"Office"}` to /csp/user/phonebook-sample/api/contact/1/$add-phone will add that phone number to contact ID 1 and respond with the full contact details (including name and all phone numbers) for that contact. 320 | 321 | ## Query via REST 322 | 323 | The final thing we want to expose in our REST API is a class query to search by phone number for a contact. This will again use an action in the `Person` class, along with a custom class query. 324 | 325 | Let's define the class query first: 326 | 327 | ```ObjectScript 328 | Query FindByPhone(phoneFragment As %String) As %SQLQuery 329 | { 330 | select distinct Person 331 | from Sample_Phonebook_Model.PhoneNumber 332 | where $Translate(PhoneNumber,' -+()') [ $Translate(:phoneFragment,' -+()') 333 | } 334 | ``` 335 | 336 | This selects IDs of Person records (important!) that have an associated phone number containing some value, removing all punctuation characters on both the input fragment and the stored phone numbers. 337 | 338 | This class query can be exposed via an action as follows: 339 | 340 | ```xml 341 | 342 | 343 | 344 | ``` 345 | 346 | This says that the "phoneFragment" URL parameter's value will be passed to the phoneFragment argument of the class query. The action name and target attach it to a `GET` request to /csp/user/phonebook-sample/api/contact/$find-by-phone. Of course, this also must be enabled in `CheckPermission`: 347 | 348 | ```ObjectScript 349 | ClassMethod CheckPermission(pID As %String, pOperation As %String, pUserContext As AppS.REST.Authentication.PlatformUser) As %Boolean 350 | { 351 | Quit (pOperation = "QUERY") || (pOperation = "READ") || (pOperation = "CREATE") || (pOperation = "UPDATE") || 352 | (pOperation = "ACTION:add-phone") || (pOperation = "ACTION:find-by-phone") 353 | } 354 | ``` 355 | 356 | And that's it! We now have a fully-functional REST API. 357 | 358 | * A user can list all contacts and their phone numbers. 359 | * A user can create a new contact or update a contact's name. 360 | * A user can add, remove, and update phone numbers for a contact. 361 | * A user can search by a string and find all contacts whose phone numbers contain that string (along with their phone numbers). 362 | 363 | ## Further reading 364 | 365 | For a different perspective on AppS.REST, check out the [User Guide](user-guide.md). 366 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /internal/testing/unit_tests/coverage.list: -------------------------------------------------------------------------------- 1 | AppS.REST.PKG 2 | AppS.Util.Buffer.CLS 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------