Repository: owncloud/ocis
Pull request #5360 (2023-01-09 16:13:44)

graph:Add stubs for education/classes endpoints
## Description
This adds an implementation of the /graph/v1.0/education/classes/ endpoints. It borrows heavily from
the /groups/ endpoints. The actual backend implementation for LDAP will follow in a separate PR.



ownclouders: :boom: Acceptance test [<strong>Core-API-Tests-ocis-storage-5</strong>](https://drone.owncloud.com/owncloud/ocis/18066/53/1) failed. Further test are cancelled...
<!-- id: pr-5360 -->

ownclouders: :boom: Acceptance test [<strong>localApiTests-apiSpacesShares-ocis</strong>](https://drone.owncloud.com/owncloud/ocis/18066/46/1) failed. Further test are cancelled...
<!-- id: pr-5360 -->

ownclouders: :boom: Acceptance test [<strong>localApiTests-apiCors-ocis</strong>](https://drone.owncloud.com/owncloud/ocis/18066/47/1) failed. Further test are cancelled...
<!-- id: pr-5360 -->

sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; [![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5360)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [![C](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/C-16px.png 'C')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [1 Bug](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [24 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL)

[![70.1%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '70.1%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list) [70.1% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list)  
[![10.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/20-16px.png '10.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list) [10.0% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; [![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5360)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [![C](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/C-16px.png 'C')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [1 Bug](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [24 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL)

[![70.1%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '70.1%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list) [70.1% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list)  
[![10.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/20-16px.png '10.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list) [10.0% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5360)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [24 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL)

[![69.5%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '69.5%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list) [69.5% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list)  
[![4.6%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/5-16px.png '4.6%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list) [4.6% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5360)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [24 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL)

[![69.5%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '69.5%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list) [69.5% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list)  
[![4.6%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/5-16px.png '4.6%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list) [4.6% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5360)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [23 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL)

[![64.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '64.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list) [64.0% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list)  
[![8.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/10-16px.png '8.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list) [8.0% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; [![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5360)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [![C](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/C-16px.png 'C')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [1 Bug](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [27 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL)

[![64.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '64.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list) [64.0% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list)  
[![8.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/10-16px.png '8.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list) [8.0% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5360)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5360&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL) [23 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5360&resolved=false&types=CODE_SMELL)

[![64.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '64.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list) [64.0% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_coverage&view=list)  
[![8.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/10-16px.png '8.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list) [8.0% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5360&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/services/graph/pkg/identity/backend.go b/services/graph/pkg/identity/backend.go
index b521e32d45d..5b8d5f5f444 100644
--- a/services/graph/pkg/identity/backend.go
+++ b/services/graph/pkg/identity/backend.go
@@ -60,6 +60,17 @@ type EducationBackend interface {
 	// RemoveUserFromEducationSchool removes a single member (by ID) from a school
 	RemoveUserFromEducationSchool(ctx context.Context, schoolID string, memberID string) error
 
+	// GetEducationClasses lists all classes
+	GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error)
+	// GetEducationClasses reads a given class by id
+	GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error)
+	// CreateEducationClass creates the supplied education class in the identity backend.
+	CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error)
+	// DeleteEducationClass deletes the supplied education class in the identity backend.
+	DeleteEducationClass(ctx context.Context, nameOrID string) error
+	// GetEducationClassMembers returns the EducationUser members for an EducationClass
+	GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error)
+
 	// CreateEducationUser creates a given education user in the identity backend.
 	CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error)
 	// DeleteEducationUser deletes a given educationuser, identified by username or id, from the backend
diff --git a/services/graph/pkg/identity/err_school.go b/services/graph/pkg/identity/err_education.go
similarity index 75%
rename from services/graph/pkg/identity/err_school.go
rename to services/graph/pkg/identity/err_education.go
index 6460fa46dfa..6e81f7c9c58 100644
--- a/services/graph/pkg/identity/err_school.go
+++ b/services/graph/pkg/identity/err_education.go
@@ -50,6 +50,31 @@ func (i *ErrEducationBackend) RemoveUserFromEducationSchool(ctx context.Context,
 	return errNotImplemented
 }
 
+// GetEducationClasses implements the EducationBackend interface
+func (i *ErrEducationBackend) GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error) {
+	return nil, errNotImplemented
+}
+
+// GetEducationClass implements the EducationBackend interface
+func (i *ErrEducationBackend) GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error) {
+	return nil, errNotImplemented
+}
+
+// CreateEducationClass implements the EducationBackend interface
+func (i *ErrEducationBackend) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
+	return nil, errNotImplemented
+}
+
+// DeleteEducationClass implements the EducationBackend interface
+func (i *ErrEducationBackend) DeleteEducationClass(ctx context.Context, nameOrID string) error {
+	return errNotImplemented
+}
+
+// GetEducationClassMembers implements the EducationBackend interface
+func (i *ErrEducationBackend) GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
+
 // CreateEducationUser creates a given education user in the identity backend.
 func (i *ErrEducationBackend) CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
 	return nil, errNotImplemented
diff --git a/services/graph/pkg/identity/ldap_education_class.go b/services/graph/pkg/identity/ldap_education_class.go
new file mode 100644
index 00000000000..4fc3082bae2
--- /dev/null
+++ b/services/graph/pkg/identity/ldap_education_class.go
@@ -0,0 +1,33 @@
+package identity
+
+import (
+	"context"
+	"net/url"
+
+	libregraph "github.com/owncloud/libre-graph-api-go"
+)
+
+// GetEducationClasses implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error) {
+	return nil, errNotImplemented
+}
+
+// CreateEducationClass implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
+	return nil, errNotImplemented
+}
+
+// GetEducationClass implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error) {
+	return nil, errNotImplemented
+}
+
+// DeleteEducationClass implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) DeleteEducationClass(ctx context.Context, nameOrID string) error {
+	return errNotImplemented
+}
+
+// GetEducationClassMembers implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
diff --git a/services/graph/pkg/identity/ldap_school.go b/services/graph/pkg/identity/ldap_education_school.go
similarity index 100%
rename from services/graph/pkg/identity/ldap_school.go
rename to services/graph/pkg/identity/ldap_education_school.go
diff --git a/services/graph/pkg/identity/ldap_school_test.go b/services/graph/pkg/identity/ldap_education_school_test.go
similarity index 100%
rename from services/graph/pkg/identity/ldap_school_test.go
rename to services/graph/pkg/identity/ldap_education_school_test.go
diff --git a/services/graph/pkg/identity/mocks/education_backend.go b/services/graph/pkg/identity/mocks/education_backend.go
index 772299ba9ef..2c2b4868b72 100644
--- a/services/graph/pkg/identity/mocks/education_backend.go
+++ b/services/graph/pkg/identity/mocks/education_backend.go
@@ -31,6 +31,29 @@ func (_m *EducationBackend) AddUsersToEducationSchool(ctx context.Context, schoo
 	return r0
 }
 
+// CreateEducationClass provides a mock function with given fields: ctx, class
+func (_m *EducationBackend) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
+	ret := _m.Called(ctx, class)
+
+	var r0 *libregraph.EducationClass
+	if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationClass) *libregraph.EducationClass); ok {
+		r0 = rf(ctx, class)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*libregraph.EducationClass)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, libregraph.EducationClass) error); ok {
+		r1 = rf(ctx, class)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
 // CreateEducationSchool provides a mock function with given fields: ctx, group
 func (_m *EducationBackend) CreateEducationSchool(ctx context.Context, group libregraph.EducationSchool) (*libregraph.EducationSchool, error) {
 	ret := _m.Called(ctx, group)
@@ -77,6 +100,20 @@ func (_m *EducationBackend) CreateEducationUser(ctx context.Context, user libreg
 	return r0, r1
 }
 
+// DeleteEducationClass provides a mock function with given fields: ctx, nameOrID
+func (_m *EducationBackend) DeleteEducationClass(ctx context.Context, nameOrID string) error {
+	ret := _m.Called(ctx, nameOrID)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
+		r0 = rf(ctx, nameOrID)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
 // DeleteEducationSchool provides a mock function with given fields: ctx, id
 func (_m *EducationBackend) DeleteEducationSchool(ctx context.Context, id string) error {
 	ret := _m.Called(ctx, id)
@@ -105,6 +142,75 @@ func (_m *EducationBackend) DeleteEducationUser(ctx context.Context, nameOrID st
 	return r0
 }
 
+// GetEducationClass provides a mock function with given fields: ctx, namedOrID, queryParam
+func (_m *EducationBackend) GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error) {
+	ret := _m.Called(ctx, namedOrID, queryParam)
+
+	var r0 *libregraph.EducationClass
+	if rf, ok := ret.Get(0).(func(context.Context, string, url.Values) *libregraph.EducationClass); ok {
+		r0 = rf(ctx, namedOrID, queryParam)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*libregraph.EducationClass)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string, url.Values) error); ok {
+		r1 = rf(ctx, namedOrID, queryParam)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// GetEducationClassMembers provides a mock function with given fields: ctx, nameOrID
+func (_m *EducationBackend) GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) {
+	ret := _m.Called(ctx, nameOrID)
+
+	var r0 []*libregraph.EducationUser
+	if rf, ok := ret.Get(0).(func(context.Context, string) []*libregraph.EducationUser); ok {
+		r0 = rf(ctx, nameOrID)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]*libregraph.EducationUser)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
+		r1 = rf(ctx, nameOrID)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// GetEducationClasses provides a mock function with given fields: ctx, queryParam
+func (_m *EducationBackend) GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error) {
+	ret := _m.Called(ctx, queryParam)
+
+	var r0 []*libregraph.EducationClass
+	if rf, ok := ret.Get(0).(func(context.Context, url.Values) []*libregraph.EducationClass); ok {
+		r0 = rf(ctx, queryParam)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]*libregraph.EducationClass)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, url.Values) error); ok {
+		r1 = rf(ctx, queryParam)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
 // GetEducationSchool provides a mock function with given fields: ctx, nameOrID, queryParam
 func (_m *EducationBackend) GetEducationSchool(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationSchool, error) {
 	ret := _m.Called(ctx, nameOrID, queryParam)
diff --git a/services/graph/pkg/service/v0/educationclasses.go b/services/graph/pkg/service/v0/educationclasses.go
new file mode 100644
index 00000000000..d50a1768eec
--- /dev/null
+++ b/services/graph/pkg/service/v0/educationclasses.go
@@ -0,0 +1,433 @@
+package svc
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"sort"
+	"strings"
+
+	"github.com/CiscoM31/godata"
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/go-chi/render"
+)
+
+// GetEducationClasses implements the Service interface.
+func (g Graph) GetEducationClasses(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Interface("query", r.URL.Query()).Msg("calling GetEducationClasses")
+	sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
+	odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
+	if err != nil {
+		logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg(
+			"could not get educationClasses: query error")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	classes, err := g.identityEducationBackend.GetEducationClasses(r.Context(), r.URL.Query())
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not get classes: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	classes, err = sortClasses(odataReq, classes)
+	if err != nil {
+		logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get classes: could not sort classes according to query")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
+		return
+	}
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, &ListResponse{Value: classes})
+}
+
+// PostEducationClass implements the Service interface.
+func (g Graph) PostEducationClass(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling post EducationClass")
+	class := libregraph.NewEducationClassWithDefaults()
+	err := json.NewDecoder(r.Body).Decode(class)
+	if err != nil {
+		logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create education class: invalid request body")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
+		return
+	}
+
+	if _, ok := class.GetDisplayNameOk(); !ok {
+		logger.Debug().Err(err).Interface("class", class).Msg("could not create class: missing required attribute")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute")
+		return
+	}
+
+	// Disallow user-supplied IDs. It's supposed to be readonly. We're either
+	// generating them in the backend ourselves or rely on the Backend's
+	// storage (e.g. LDAP) to provide a unique ID.
+	if _, ok := class.GetIdOk(); ok {
+		logger.Debug().Msg("could not create class: id is a read-only attribute")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "class id is a read-only attribute")
+		return
+	}
+
+	if class, err = g.identityEducationBackend.CreateEducationClass(r.Context(), *class); err != nil {
+		logger.Debug().Interface("class", class).Msg("could not create class: backend error")
+		errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	/* TODO requires reva changes
+	if class != nil && class.Id != nil {
+		currentUser := revactx.ContextMustGetUser(r.Context())
+		g.publishEvent(events.EducationClassCreated{Executant: currentUser.Id, EducationClassID: *class.Id})
+	}
+	*/
+	render.Status(r, http.StatusCreated)
+	render.JSON(w, r, class)
+}
+
+// PatchEducationClass implements the Service interface.
+func (g Graph) PatchEducationClass(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling patch education class")
+	classID := chi.URLParam(r, "classID")
+	classID, err := url.PathUnescape(classID)
+	if err != nil {
+		logger.Debug().Str("id", classID).Msg("could not change class: unescaping class id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
+		return
+	}
+
+	if classID == "" {
+		logger.Debug().Msg("could not change class: missing class id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
+		return
+	}
+	changes := libregraph.NewEducationClassWithDefaults()
+	err = json.NewDecoder(r.Body).Decode(changes)
+	if err != nil {
+		logger.Debug().Err(err).Interface("body", r.Body).Msg("could not change class: invalid request body")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
+		return
+	}
+
+	if memberRefs, ok := changes.GetMembersodataBindOk(); ok {
+		// The spec defines a limit of 20 members maxium per Request
+		if len(memberRefs) > g.config.API.GroupMembersPatchLimit {
+			logger.Debug().
+				Int("number", len(memberRefs)).
+				Int("limit", g.config.API.GroupMembersPatchLimit).
+				Msg("could not add group members, exceeded members limit")
+			errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
+				fmt.Sprintf("Request is limited to %d members", g.config.API.GroupMembersPatchLimit))
+			return
+		}
+		memberIDs := make([]string, 0, len(memberRefs))
+		for _, memberRef := range memberRefs {
+			memberType, id, err := g.parseMemberRef(memberRef)
+			if err != nil {
+				logger.Debug().
+					Str("memberref", memberRef).
+					Msg("could not change class: Error parsing member@odata.bind values")
+				errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing member@odata.bind values")
+				return
+			}
+			logger.Debug().Str("membertype", memberType).Str("memberid", id).Msg("add class member")
+			// The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact"
+			// we restrict this to users for now. Might add Classes as members later
+			if memberType != memberTypeUsers {
+				logger.Debug().
+					Str("type", memberType).
+					Msg("could not change class: could not add member, only user type is allowed")
+				errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only user are allowed as class members")
+				return
+			}
+			memberIDs = append(memberIDs, id)
+		}
+		err = g.identityBackend.AddMembersToGroup(r.Context(), classID, memberIDs)
+	}
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not change class: backend could not add members")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+	render.Status(r, http.StatusNoContent) // TODO StatusNoContent when prefer=minimal is used, otherwise OK and the resource in the body
+	render.NoContent(w, r)
+}
+
+// GetEducationClass implements the Service interface.
+func (g Graph) GetEducationClass(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling get education class")
+	classID := chi.URLParam(r, "classID")
+	classID, err := url.PathUnescape(classID)
+	if err != nil {
+		logger.Debug().Str("id", classID).Msg("could not get class: unescaping class id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
+	}
+
+	if classID == "" {
+		logger.Debug().Msg("could not get class: missing class id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
+		return
+	}
+
+	logger.Debug().
+		Str("id", classID).
+		Interface("query", r.URL.Query()).
+		Msg("calling get class on backend")
+	class, err := g.identityEducationBackend.GetEducationClass(r.Context(), classID, r.URL.Query())
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not get class: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+	}
+
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, class)
+}
+
+// DeleteEducationClass implements the Service interface.
+func (g Graph) DeleteEducationClass(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling delete class")
+	classID := chi.URLParam(r, "classID")
+	classID, err := url.PathUnescape(classID)
+	if err != nil {
+		logger.Debug().Err(err).Str("id", classID).Msg("could not delete class: unescaping class id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
+		return
+	}
+
+	if classID == "" {
+		logger.Debug().Msg("could not delete class: missing class id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
+		return
+	}
+
+	logger.Debug().Str("id", classID).Msg("calling delete class on backend")
+	err = g.identityEducationBackend.DeleteEducationClass(r.Context(), classID)
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not delete class: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	/* TODO requires reva changes
+	currentUser := revactx.ContextMustGetUser(r.Context())
+	g.publishEvent(events.ClassDeleted{Executant: currentUser.Id, ClassID: classID})
+	*/
+
+	render.Status(r, http.StatusNoContent)
+	render.NoContent(w, r)
+}
+
+// GetEducationClassMembers implements the Service interface.
+func (g Graph) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling get class members")
+	classID := chi.URLParam(r, "classID")
+	classID, err := url.PathUnescape(classID)
+	if err != nil {
+		logger.Debug().Str("id", classID).Msg("could not get class members: unescaping class id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
+		return
+	}
+
+	if classID == "" {
+		logger.Debug().Msg("could not get class members: missing class id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
+		return
+	}
+
+	logger.Debug().Str("id", classID).Msg("calling get class members on backend")
+	members, err := g.identityEducationBackend.GetEducationClassMembers(r.Context(), classID)
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not get class members: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, members)
+}
+
+// PostEducationClassMember implements the Service interface.
+func (g Graph) PostEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("Calling post class member")
+
+	classID := chi.URLParam(r, "classID")
+	classID, err := url.PathUnescape(classID)
+	if err != nil {
+		logger.Debug().
+			Err(err).
+			Str("id", classID).
+			Msg("could not add member to class: unescaping class id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
+		return
+	}
+
+	if classID == "" {
+		logger.Debug().Msg("could not add class member: missing class id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
+		return
+	}
+	memberRef := libregraph.NewMemberReference()
+	err = json.NewDecoder(r.Body).Decode(memberRef)
+	if err != nil {
+		logger.Debug().
+			Err(err).
+			Interface("body", r.Body).
+			Msg("could not add class member: invalid request body")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
+		return
+	}
+	memberRefURL, ok := memberRef.GetOdataIdOk()
+	if !ok {
+		logger.Debug().Msg("could not add class member: @odata.id reference is missing")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
+		return
+	}
+	memberType, id, err := g.parseMemberRef(*memberRefURL)
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not add class member: error parsing @odata.id url")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
+		return
+	}
+	// The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact"
+	// we restrict this to users for now. Might add EducationClass as members later
+	if memberType != memberTypeUsers {
+		logger.Debug().Str("type", memberType).Msg("could not add class member: Only users are allowed as class members")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as class members")
+		return
+	}
+
+	logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add member on backend")
+	err = g.identityBackend.AddMembersToGroup(r.Context(), classID, []string{id})
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not add class member: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	/* TODO requires reva changes
+	currentUser := revactx.ContextMustGetUser(r.Context())
+	g.publishEvent(events.EducationClassMemberAdded{Executant: currentUser.Id, EducationClassID: classID, UserID: id})
+	*/
+	render.Status(r, http.StatusNoContent)
+	render.NoContent(w, r)
+}
+
+// DeleteEducationClassMember implements the Service interface.
+func (g Graph) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling delete class member")
+
+	classID := chi.URLParam(r, "classID")
+	classID, err := url.PathUnescape(classID)
+	if err != nil {
+		logger.Debug().Err(err).Str("id", classID).Msg("could not delete class member: unescaping class id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
+		return
+	}
+
+	if classID == "" {
+		logger.Debug().Msg("could not delete class member: missing class id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
+		return
+	}
+
+	memberID := chi.URLParam(r, "memberID")
+	memberID, err = url.PathUnescape(memberID)
+	if err != nil {
+		logger.Debug().Err(err).Str("id", memberID).Msg("could not delete class member: unescaping member id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping member id failed")
+		return
+	}
+
+	if memberID == "" {
+		logger.Debug().Msg("could not delete class member: missing member id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id")
+		return
+	}
+	logger.Debug().Str("classID", classID).Str("memberID", memberID).Msg("calling delete member on backend")
+	err = g.identityBackend.RemoveMemberFromGroup(r.Context(), classID, memberID)
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not delete class member: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+	/* TODO requires reva changes
+	currentUser := revactx.ContextMustGetUser(r.Context())
+	g.publishEvent(events.EducationClassMemberRemoved{Executant: currentUser.Id, EducationClassID: classID, UserID: memberID})
+	*/
+	render.Status(r, http.StatusNoContent)
+	render.NoContent(w, r)
+}
+
+func sortClasses(req *godata.GoDataRequest, classes []*libregraph.EducationClass) ([]*libregraph.EducationClass, error) {
+	if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
+		return classes, nil
+	}
+	var less func(i, j int) bool
+
+	switch req.Query.OrderBy.OrderByItems[0].Field.Value {
+	case displayNameAttr:
+		less = func(i, j int) bool {
+			return strings.ToLower(classes[i].GetDisplayName()) < strings.ToLower(classes[j].GetDisplayName())
+		}
+	default:
+		return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
+	}
+
+	if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending {
+		sort.Slice(classes, reverse(less))
+	} else {
+		sort.Slice(classes, less)
+	}
+
+	return classes, nil
+}
diff --git a/services/graph/pkg/service/v0/groups.go b/services/graph/pkg/service/v0/groups.go
index 283676b9503..90ee6c18931 100644
--- a/services/graph/pkg/service/v0/groups.go
+++ b/services/graph/pkg/service/v0/groups.go
@@ -127,7 +127,7 @@ func (g Graph) PatchGroup(w http.ResponseWriter, r *http.Request) {
 			logger.Debug().
 				Int("number", len(memberRefs)).
 				Int("limit", g.config.API.GroupMembersPatchLimit).
-				Msg("could not create group, exceeded members limit")
+				Msg("could not add group members, exceeded members limit")
 			errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
 				fmt.Sprintf("Request is limited to %d members", g.config.API.GroupMembersPatchLimit))
 			return
diff --git a/services/graph/pkg/service/v0/instrument.go b/services/graph/pkg/service/v0/instrument.go
index eafb980fef9..1c629bab88c 100644
--- a/services/graph/pkg/service/v0/instrument.go
+++ b/services/graph/pkg/service/v0/instrument.go
@@ -139,6 +139,46 @@ func (i instrument) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Req
 	i.next.DeleteEducationSchoolUser(w, r)
 }
 
+// GetEducationClasses implements the Service interface.
+func (i instrument) GetEducationClasses(w http.ResponseWriter, r *http.Request) {
+	i.next.GetEducationClasses(w, r)
+}
+
+// GetEducationClass implements the Service interface.
+func (i instrument) GetEducationClass(w http.ResponseWriter, r *http.Request) {
+	i.next.GetEducationClass(w, r)
+}
+
+// PostEducationClass implements the Service interface.
+func (i instrument) PostEducationClass(w http.ResponseWriter, r *http.Request) {
+	i.next.PostEducationClass(w, r)
+}
+
+// PatchEducationClass implements the Service interface.
+func (i instrument) PatchEducationClass(w http.ResponseWriter, r *http.Request) {
+	i.next.PatchEducationClass(w, r)
+}
+
+// DeleteEducationClass implements the Service interface.
+func (i instrument) DeleteEducationClass(w http.ResponseWriter, r *http.Request) {
+	i.next.DeleteEducationClass(w, r)
+}
+
+// GetEducationClassMembers implements the Service interface.
+func (i instrument) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) {
+	i.next.GetEducationClassMembers(w, r)
+}
+
+// PostEducationClassMember implements the Service interface.
+func (i instrument) PostEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	i.next.PostEducationClassMember(w, r)
+}
+
+// DeleteEducationClassMember implements the Service interface.
+func (i instrument) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	i.next.DeleteEducationClassMember(w, r)
+}
+
 // GetEducationUsers implements the Service interface.
 func (i instrument) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
 	i.next.GetEducationUsers(w, r)
diff --git a/services/graph/pkg/service/v0/logging.go b/services/graph/pkg/service/v0/logging.go
index 1e3369b6ce1..715739b8772 100644
--- a/services/graph/pkg/service/v0/logging.go
+++ b/services/graph/pkg/service/v0/logging.go
@@ -139,6 +139,46 @@ func (l logging) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Reques
 	l.next.DeleteEducationSchoolUser(w, r)
 }
 
+// GetEducationClasses implements the Service interface.
+func (l logging) GetEducationClasses(w http.ResponseWriter, r *http.Request) {
+	l.next.GetEducationClasses(w, r)
+}
+
+// GetEducationClass implements the Service interface.
+func (l logging) GetEducationClass(w http.ResponseWriter, r *http.Request) {
+	l.next.GetEducationClass(w, r)
+}
+
+// PostEducationClass implements the Service interface.
+func (l logging) PostEducationClass(w http.ResponseWriter, r *http.Request) {
+	l.next.PostEducationClass(w, r)
+}
+
+// PatchEducationClass implements the Service interface.
+func (l logging) PatchEducationClass(w http.ResponseWriter, r *http.Request) {
+	l.next.PatchEducationClass(w, r)
+}
+
+// DeleteEducationClass implements the Service interface.
+func (l logging) DeleteEducationClass(w http.ResponseWriter, r *http.Request) {
+	l.next.DeleteEducationClass(w, r)
+}
+
+// GetEducationClassMembers implements the Service interface.
+func (l logging) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) {
+	l.next.GetEducationClassMembers(w, r)
+}
+
+// PostEducationClassMember implements the Service interface.
+func (l logging) PostEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	l.next.PostEducationClassMember(w, r)
+}
+
+// DeleteEducationClassMember implements the Service interface.
+func (l logging) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	l.next.DeleteEducationClassMember(w, r)
+}
+
 // GetEducationUsers implements the Service interface.
 func (l logging) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
 	l.next.GetEducationUsers(w, r)
diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go
index 98ce2d1a27a..fedaf6351e8 100644
--- a/services/graph/pkg/service/v0/service.go
+++ b/services/graph/pkg/service/v0/service.go
@@ -58,11 +58,20 @@ type Service interface {
 	PostEducationSchoolUser(http.ResponseWriter, *http.Request)
 	DeleteEducationSchoolUser(http.ResponseWriter, *http.Request)
 
+	GetEducationClasses(http.ResponseWriter, *http.Request)
+	GetEducationClass(http.ResponseWriter, *http.Request)
+	PostEducationClass(http.ResponseWriter, *http.Request)
+	PatchEducationClass(http.ResponseWriter, *http.Request)
+	DeleteEducationClass(w http.ResponseWriter, r *http.Request)
+	GetEducationClassMembers(w http.ResponseWriter, r *http.Request)
+	PostEducationClassMember(w http.ResponseWriter, r *http.Request)
+
 	GetEducationUsers(http.ResponseWriter, *http.Request)
 	GetEducationUser(http.ResponseWriter, *http.Request)
 	PostEducationUser(http.ResponseWriter, *http.Request)
 	DeleteEducationUser(http.ResponseWriter, *http.Request)
 	PatchEducationUser(http.ResponseWriter, *http.Request)
+	DeleteEducationClassMember(w http.ResponseWriter, r *http.Request)
 
 	GetDrives(w http.ResponseWriter, r *http.Request)
 	GetSingleDrive(w http.ResponseWriter, r *http.Request)
@@ -293,16 +302,16 @@ func NewService(opts ...Option) (Graph, error) {
 					})
 				})
 				r.Route("/classes", func(r chi.Router) {
-					r.Get("/", svc.GetGroups)
-					r.Post("/", svc.PostGroup)
-					r.Route("/{groupID}", func(r chi.Router) {
-						r.Get("/", svc.GetGroup)
-						r.Delete("/", svc.DeleteGroup)
-						r.Patch("/", svc.PatchGroup)
+					r.Get("/", svc.GetEducationClasses)
+					r.Post("/", svc.PostEducationClass)
+					r.Route("/{classID}", func(r chi.Router) {
+						r.Get("/", svc.GetEducationClass)
+						r.Delete("/", svc.DeleteEducationClass)
+						r.Patch("/", svc.PatchEducationClass)
 						r.Route("/members", func(r chi.Router) {
-							r.Get("/", svc.GetGroupMembers)
-							r.Post("/$ref", svc.PostGroupMember)
-							r.Delete("/{memberID}/$ref", svc.DeleteGroupMember)
+							r.Get("/", svc.GetEducationClassMembers)
+							r.Post("/$ref", svc.PostEducationClassMember)
+							r.Delete("/{memberID}/$ref", svc.DeleteEducationClassMember)
 						})
 					})
 				})
diff --git a/services/graph/pkg/service/v0/tracing.go b/services/graph/pkg/service/v0/tracing.go
index 160289f7416..691df5f11e7 100644
--- a/services/graph/pkg/service/v0/tracing.go
+++ b/services/graph/pkg/service/v0/tracing.go
@@ -135,6 +135,46 @@ func (t tracing) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Reques
 	t.next.DeleteEducationSchoolUser(w, r)
 }
 
+// GetEducationClasses implements the Service interface.
+func (t tracing) GetEducationClasses(w http.ResponseWriter, r *http.Request) {
+	t.next.GetEducationClasses(w, r)
+}
+
+// GetEducationClass implements the Service interface.
+func (t tracing) GetEducationClass(w http.ResponseWriter, r *http.Request) {
+	t.next.GetEducationClass(w, r)
+}
+
+// PostEducationClass implements the Service interface.
+func (t tracing) PostEducationClass(w http.ResponseWriter, r *http.Request) {
+	t.next.PostEducationClass(w, r)
+}
+
+// PatchEducationClass implements the Service interface.
+func (t tracing) PatchEducationClass(w http.ResponseWriter, r *http.Request) {
+	t.next.PatchEducationClass(w, r)
+}
+
+// DeleteEducationClass implements the Service interface.
+func (t tracing) DeleteEducationClass(w http.ResponseWriter, r *http.Request) {
+	t.next.DeleteEducationClass(w, r)
+}
+
+// GetEducationClassMembers implements the Service interface.
+func (t tracing) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) {
+	t.next.GetEducationClassMembers(w, r)
+}
+
+// PostEducationClassMember implements the Service interface.
+func (t tracing) PostEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	t.next.PostEducationClassMember(w, r)
+}
+
+// DeleteEducationClassMember implements the Service interface.
+func (t tracing) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	t.next.DeleteEducationClassMember(w, r)
+}
+
 // GetEducationUsers implements the Service interface.
 func (t tracing) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
 	t.next.GetEducationUsers(w, r)


Test patch:
diff --git a/services/graph/pkg/service/v0/educationclasses_test.go b/services/graph/pkg/service/v0/educationclasses_test.go
new file mode 100644
index 00000000000..850e1b8976b
--- /dev/null
+++ b/services/graph/pkg/service/v0/educationclasses_test.go
@@ -0,0 +1,530 @@
+package svc_test
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+
+	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	revactx "github.com/cs3org/reva/v2/pkg/ctx"
+	"github.com/go-chi/chi/v5"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/test-go/testify/mock"
+
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	"github.com/owncloud/ocis/v2/ocis-pkg/log"
+	ogrpc "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
+	"github.com/owncloud/ocis/v2/ocis-pkg/shared"
+	"github.com/owncloud/ocis/v2/services/graph/mocks"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/config"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults"
+	identitymocks "github.com/owncloud/ocis/v2/services/graph/pkg/identity/mocks"
+	service "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
+)
+
+type classList struct {
+	Value []*libregraph.EducationClass
+}
+
+var _ = Describe("EducationClass", func() {
+	var (
+		svc                      service.Service
+		ctx                      context.Context
+		cfg                      *config.Config
+		gatewayClient            *mocks.GatewayClient
+		eventsPublisher          mocks.Publisher
+		identityBackend          *identitymocks.Backend
+		identityEducationBackend *identitymocks.EducationBackend
+
+		rr *httptest.ResponseRecorder
+
+		newClass    *libregraph.EducationClass
+		currentUser = &userv1beta1.User{
+			Id: &userv1beta1.UserId{
+				OpaqueId: "user",
+			},
+		}
+	)
+
+	BeforeEach(func() {
+		eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+		identityEducationBackend = &identitymocks.EducationBackend{}
+		identityBackend = &identitymocks.Backend{}
+		gatewayClient = &mocks.GatewayClient{}
+		newClass = libregraph.NewEducationClass("math", "course")
+		newClass.SetMembersodataBind([]string{"/users/user1"})
+		newClass.SetId("math")
+
+		rr = httptest.NewRecorder()
+		ctx = context.Background()
+
+		cfg = defaults.FullDefaultConfig()
+		cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
+		cfg.TokenManager.JWTSecret = "loremipsum"
+		cfg.Commons = &shared.Commons{}
+		cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
+
+		_ = ogrpc.Configure(ogrpc.GetClientOptions(cfg.GRPCClientTLS)...)
+		svc, _ = service.NewService(
+			service.Config(cfg),
+			service.WithGatewayClient(gatewayClient),
+			service.EventsPublisher(&eventsPublisher),
+			service.WithIdentityBackend(identityBackend),
+			service.WithIdentityEducationBackend(identityEducationBackend),
+			service.Logger(log.NewLogger(log.Level("debug"))),
+		)
+	})
+
+	Describe("GetEducationClasses", func() {
+		It("handles invalid ODATA parameters", func() {
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes?§foo=bar", nil)
+			svc.GetEducationClasses(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles invalid sorting queries", func() {
+			identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{newClass}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes?$orderby=invalid", nil)
+			svc.GetEducationClasses(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			odataerr := libregraph.OdataError{}
+			err = json.Unmarshal(data, &odataerr)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(odataerr.Error.Code).To(Equal("invalidRequest"))
+		})
+
+		It("handles unknown backend errors", func() {
+			identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return(nil, errors.New("failed"))
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
+			svc.GetEducationClasses(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusInternalServerError))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			odataerr := libregraph.OdataError{}
+			err = json.Unmarshal(data, &odataerr)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(odataerr.Error.Code).To(Equal("generalException"))
+		})
+
+		It("handles backend errors", func() {
+			identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
+			svc.GetEducationClasses(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusInternalServerError))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			odataerr := libregraph.OdataError{}
+			err = json.Unmarshal(data, &odataerr)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(odataerr.Error.Code).To(Equal("accessDenied"))
+		})
+
+		It("renders an empty list of classes", func() {
+			identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
+			svc.GetEducationClasses(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			res := service.ListResponse{}
+			err = json.Unmarshal(data, &res)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(res.Value).To(Equal([]interface{}{}))
+		})
+
+		It("renders a list of classes", func() {
+			identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{newClass}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
+			svc.GetEducationClasses(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			res := groupList{}
+			err = json.Unmarshal(data, &res)
+			Expect(err).ToNot(HaveOccurred())
+
+			Expect(len(res.Value)).To(Equal(1))
+			Expect(res.Value[0].GetId()).To(Equal("math"))
+		})
+	})
+
+	Describe("GetEducationClass", func() {
+		It("handles missing or empty class id", func() {
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
+			svc.GetEducationClass(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+
+			r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", "")
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
+			svc.GetEducationClass(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+		Context("with an existing class", func() {
+			BeforeEach(func() {
+				identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
+			})
+
+			It("gets the class", func() {
+				r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/"+*newClass.Id, nil)
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("classID", *newClass.Id)
+				r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
+
+				svc.GetEducationClass(rr, r)
+
+				Expect(rr.Code).To(Equal(http.StatusOK))
+			})
+		})
+	})
+
+	Describe("PostEducationClass", func() {
+		It("handles invalid body", func() {
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBufferString("{invalid"))
+
+			svc.PostEducationClass(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles missing display name", func() {
+			newClass = libregraph.NewEducationClassWithDefaults()
+			newClass.SetId("disallowed")
+			newClass.SetMembersodataBind([]string{"/non-users/user"})
+			newClassJson, err := json.Marshal(newClass)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBuffer(newClassJson))
+
+			svc.PostEducationClass(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("disallows group create ids", func() {
+			newClass = libregraph.NewEducationClassWithDefaults()
+			newClass.SetId("disallowed")
+			newClass.SetDisplayName("New Class")
+			newClass.SetMembersodataBind([]string{"/non-users/user"})
+			newClassJson, err := json.Marshal(newClass)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBuffer(newClassJson))
+
+			svc.PostEducationClass(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("creates the Class", func() {
+			newClass = libregraph.NewEducationClassWithDefaults()
+			newClass.SetDisplayName("New Class")
+			newClassJson, err := json.Marshal(newClass)
+			Expect(err).ToNot(HaveOccurred())
+
+			identityEducationBackend.On("CreateEducationClass", mock.Anything, mock.Anything).Return(newClass, nil)
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/class/", bytes.NewBuffer(newClassJson))
+
+			svc.PostEducationClass(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusCreated))
+		})
+	})
+
+	Describe("PatchEducationClass", func() {
+		It("handles invalid body", func() {
+			r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes/", bytes.NewBufferString("{invalid"))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PatchEducationClass(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles missing or empty group id", func() {
+			r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
+			svc.PatchEducationClass(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+
+			r = httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", "")
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PatchEducationClass(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		Context("with an existing group", func() {
+			BeforeEach(func() {
+				identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
+			})
+
+			It("fails when the number of users is exceeded - spec says 20 max", func() {
+				updatedClass := libregraph.NewEducationClassWithDefaults()
+				updatedClass.SetDisplayName("class updated")
+				updatedClass.SetMembersodataBind([]string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
+					"19", "20", "21"})
+				updatedClassJson, err := json.Marshal(updatedClass)
+				Expect(err).ToNot(HaveOccurred())
+
+				r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("classID", *newClass.Id)
+				r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+				svc.PatchEducationClass(rr, r)
+
+				resp, err := ioutil.ReadAll(rr.Body)
+				Expect(err).ToNot(HaveOccurred())
+
+				Expect(string(resp)).To(ContainSubstring("Request is limited to 20"))
+				Expect(rr.Code).To(Equal(http.StatusBadRequest))
+			})
+
+			It("succeeds when the number of users is over 20 but the limit is raised to 21", func() {
+				updatedClass := libregraph.NewEducationClassWithDefaults()
+				updatedClass.SetDisplayName("group1 updated")
+				updatedClass.SetMembersodataBind([]string{
+					"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
+					"19", "20", "21",
+				})
+				updatedClassJson, err := json.Marshal(updatedClass)
+				Expect(err).ToNot(HaveOccurred())
+
+				cfg.API.GroupMembersPatchLimit = 21
+				svc, _ = service.NewService(
+					service.Config(cfg),
+					service.WithGatewayClient(gatewayClient),
+					service.EventsPublisher(&eventsPublisher),
+					service.WithIdentityBackend(identityBackend),
+				)
+
+				r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("classID", *newClass.Id)
+				r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+				svc.PatchEducationClass(rr, r)
+
+				resp, err := ioutil.ReadAll(rr.Body)
+				Expect(err).ToNot(HaveOccurred())
+
+				Expect(string(resp)).To(ContainSubstring("Error parsing member@odata.bind values"))
+				Expect(rr.Code).To(Equal(http.StatusBadRequest))
+			})
+
+			It("fails on invalid user refs", func() {
+				updatedClass := libregraph.NewEducationClassWithDefaults()
+				updatedClass.SetDisplayName("class updated")
+				updatedClass.SetMembersodataBind([]string{"invalid"})
+				updatedClassJson, err := json.Marshal(updatedClass)
+				Expect(err).ToNot(HaveOccurred())
+
+				r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("classID", *newClass.Id)
+				r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+				svc.PatchEducationClass(rr, r)
+
+				Expect(rr.Code).To(Equal(http.StatusBadRequest))
+			})
+
+			It("fails when the adding non-users users", func() {
+				updatedClass := libregraph.NewEducationClassWithDefaults()
+				updatedClass.SetDisplayName("group1 updated")
+				updatedClass.SetMembersodataBind([]string{"/non-users/user1"})
+				updatedClassJson, err := json.Marshal(updatedClass)
+				Expect(err).ToNot(HaveOccurred())
+
+				r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("classID", *newClass.Id)
+				r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+				svc.PatchEducationClass(rr, r)
+
+				Expect(rr.Code).To(Equal(http.StatusBadRequest))
+			})
+
+			It("adds members to the class", func() {
+				identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+				updatedClass := libregraph.NewEducationClassWithDefaults()
+				updatedClass.SetDisplayName("Class updated")
+				updatedClass.SetMembersodataBind([]string{"/users/user1"})
+				updatedClassJson, err := json.Marshal(updatedClass)
+				Expect(err).ToNot(HaveOccurred())
+
+				r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("classID", *newClass.Id)
+				r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+				svc.PatchEducationClass(rr, r)
+
+				Expect(rr.Code).To(Equal(http.StatusNoContent))
+				identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
+			})
+		})
+	})
+
+	Describe("DeleteEducationClass", func() {
+		Context("with an existing EducationClass", func() {
+			BeforeEach(func() {
+				identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
+			})
+		})
+
+		It("deletes the EducationClass", func() {
+			identityEducationBackend.On("DeleteEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+			r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationClass(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusNoContent))
+			identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationClass", 1)
+			//eventsPublisher.AssertNumberOfCalls(GinkgoT(), "Publish", 1)
+		})
+	})
+
+	Describe("GetEducationClassMembers", func() {
+		It("gets the list of members", func() {
+			user := libregraph.NewEducationUser()
+			user.SetId("user")
+			identityEducationBackend.On("GetEducationClassMembers", mock.Anything, mock.Anything, mock.Anything).
+				Return([]*libregraph.EducationUser{user}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/{classID}/members", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.GetEducationClassMembers(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusOK))
+
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			var members []*libregraph.EducationUser
+			err = json.Unmarshal(data, &members)
+			Expect(err).ToNot(HaveOccurred())
+
+			Expect(len(members)).To(Equal(1))
+			Expect(members[0].GetId()).To(Equal("user"))
+		})
+	})
+
+	Describe("PostEducationClassMember", func() {
+		It("fails on invalid body", func() {
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBufferString("{invalid"))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PostEducationClassMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("fails on missing member refs", func() {
+			member := libregraph.NewMemberReference()
+			data, err := json.Marshal(member)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PostEducationClassMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("fails on invalid member refs", func() {
+			member := libregraph.NewMemberReference()
+			member.SetOdataId("/invalidtype/user")
+			data, err := json.Marshal(member)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PostEducationClassMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("adds a new member", func() {
+			member := libregraph.NewMemberReference()
+			member.SetOdataId("/users/user")
+			data, err := json.Marshal(member)
+			Expect(err).ToNot(HaveOccurred())
+			identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PostEducationClassMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusNoContent))
+
+			identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
+		})
+	})
+
+	Describe("DeleteEducationClassMembers", func() {
+		It("handles missing or empty member id", func() {
+			r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationClassMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles missing or empty member id", func() {
+			r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("memberID", "/users/user")
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationClassMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("deletes members", func() {
+			identityBackend.On("RemoveMemberFromGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+			r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			rctx.URLParams.Add("memberID", "/users/user1")
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationClassMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusNoContent))
+
+			identityBackend.AssertNumberOfCalls(GinkgoT(), "RemoveMemberFromGroup", 1)
+		})
+	})
+})