Repository: TBD54566975/ssi-service
Pull request #231 (2022-12-22 22:35:57)

[OSE-301] Implement Create, Get, List, Delete endpoints for issuance templates
# Overview
This PR implements Create, Get, List, Delete endpoints for issuance templates.

# Description
This is very similar to the existing CRUD endpoints. The only main difference is that there are validation checks for some of the fields including the `schema`, `ID` and `expiry` of all the `IssuanceTemplate.Credentials` objects. 

# How Has This Been Tested?
Added tests for each of the endpoints. 

# Checklist

Before submitting this PR, please make sure:

- [x] I have read the CONTRIBUTING document.
- [x] My code is consistent with the rest of the project 
- [x] I have tagged the relevant reviewers and/or interested parties
- [x] I have updated the READMEs and other documentation of affected packages

## References
SIP5


codecov-commenter: # [Codecov](https://codecov.io/gh/TBD54566975/ssi-service/pull/231?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975) Report
> Merging [#231](https://codecov.io/gh/TBD54566975/ssi-service/pull/231?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975) (9170423) into [main](https://codecov.io/gh/TBD54566975/ssi-service/commit/f591c33943b01466b4ea8aa047cc5e4cf5fce1f1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975) (f591c33) will **decrease** coverage by `0.38%`.
> The diff coverage is `0.00%`.

```diff
@@            Coverage Diff             @@
##             main     #231      +/-   ##
==========================================
- Coverage   23.33%   22.95%   -0.39%     
==========================================
  Files          27       27              
  Lines        2507     2549      +42     
==========================================
  Hits          585      585              
- Misses       1821     1863      +42     
  Partials      101      101              
```


| [Impacted Files](https://codecov.io/gh/TBD54566975/ssi-service/pull/231?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975) | Coverage Δ | |
|---|---|---|
| [pkg/server/router/issuance.go](https://codecov.io/gh/TBD54566975/ssi-service/pull/231/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975#diff-cGtnL3NlcnZlci9yb3V0ZXIvaXNzdWFuY2UuZ28=) | `0.00% <0.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=TBD54566975)


Patch:
diff --git a/pkg/server/router/issuance.go b/pkg/server/router/issuance.go
index d81e04b..10b7c72 100644
--- a/pkg/server/router/issuance.go
+++ b/pkg/server/router/issuance.go
@@ -5,7 +5,9 @@ import (
 	"net/http"
 
 	"github.com/pkg/errors"
-	"github.com/tbd54566975/ssi-service/pkg/service/framework"
+	"github.com/tbd54566975/ssi-service/internal/util"
+	"github.com/tbd54566975/ssi-service/pkg/server/framework"
+	svcframework "github.com/tbd54566975/ssi-service/pkg/service/framework"
 	"github.com/tbd54566975/ssi-service/pkg/service/issuing"
 )
 
@@ -13,7 +15,7 @@ type IssuanceRouter struct {
 	service issuing.Service
 }
 
-func NewIssuanceRouter(svc framework.Service) (*IssuanceRouter, error) {
+func NewIssuanceRouter(svc svcframework.Service) (*IssuanceRouter, error) {
 	service, ok := svc.(*issuing.Service)
 	if !ok {
 		return nil, errors.New("could not cast to issuing service type")
@@ -28,17 +30,34 @@ func NewIssuanceRouter(svc framework.Service) (*IssuanceRouter, error) {
 // @Accept       json
 // @Produce      json
 // @Param        id   path      string  true  "ID"
-// @Success      200  {object}  issuing.GetIssuanceTemplateResponse
+// @Success      200  {object}  issuing.IssuanceTemplate
 // @Failure      400  {string}  string  "Bad request"
 // @Router       /v1/issuancetemplates/{id} [get]
-func (ir IssuanceRouter) GetIssuanceTemplate(ctx context.Context, w http.ResponseWriter, req *http.Request) error {
-	return nil
+func (ir IssuanceRouter) GetIssuanceTemplate(ctx context.Context, w http.ResponseWriter, _ *http.Request) error {
+	id := framework.GetParam(ctx, IDParam)
+	if id == nil {
+		return framework.NewRequestError(
+			util.LoggingNewError("cannot get issuance template without an ID"), http.StatusBadRequest)
+	}
+
+	issuanceTemplate, err := ir.service.GetIssuanceTemplate(&issuing.GetIssuanceTemplateRequest{ID: *id})
+	if err != nil {
+		return framework.NewRequestError(
+			util.LoggingErrorMsg(err, "getting issuance template"), http.StatusInternalServerError)
+	}
+	return framework.Respond(ctx, w, issuanceTemplate.IssuanceTemplate, http.StatusOK)
 }
 
 type CreateIssuanceTemplateRequest struct {
 	issuing.IssuanceTemplate
 }
 
+func (r CreateIssuanceTemplateRequest) ToServiceRequest() *issuing.CreateIssuanceTemplateRequest {
+	return &issuing.CreateIssuanceTemplateRequest{
+		IssuanceTemplate: r.IssuanceTemplate,
+	}
+}
+
 // CreateIssuanceTemplate godoc
 // @Summary      Create issuance template
 // @Description  Create issuance template
@@ -51,7 +70,20 @@ type CreateIssuanceTemplateRequest struct {
 // @Failure      500      {string}  string  "Internal server error"
 // @Router       /v1/issuancetemplates [put]
 func (ir IssuanceRouter) CreateIssuanceTemplate(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
-	return nil
+	var request CreateIssuanceTemplateRequest
+	errMsg := "Invalid Issuance Template Request"
+	if err := framework.Decode(r, &request); err != nil {
+		return framework.NewRequestError(
+			util.LoggingErrorMsg(err, errMsg), http.StatusBadRequest)
+	}
+
+	template, err := ir.service.CreateIssuanceTemplate(request.ToServiceRequest())
+	if err != nil {
+		return framework.NewRequestError(
+			util.LoggingErrorMsg(err, "creating issuance template"), http.StatusInternalServerError)
+	}
+
+	return framework.Respond(ctx, w, template, http.StatusCreated)
 }
 
 // DeleteIssuanceTemplate godoc
@@ -65,8 +97,19 @@ func (ir IssuanceRouter) CreateIssuanceTemplate(ctx context.Context, w http.Resp
 // @Failure      400  {string}  string  "Bad request"
 // @Failure      500  {string}  string  "Internal server error"
 // @Router       /v1/issuancetemplates/{id} [delete]
-func (ir IssuanceRouter) DeleteIssuanceTemplate(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
-	return nil
+func (ir IssuanceRouter) DeleteIssuanceTemplate(ctx context.Context, w http.ResponseWriter, _ *http.Request) error {
+	id := framework.GetParam(ctx, IDParam)
+	if id == nil {
+		return framework.NewRequestError(
+			util.LoggingNewError("cannot delete an issuance template without an ID parameter"), http.StatusBadRequest)
+	}
+
+	if err := ir.service.DeleteIssuanceTemplate(&issuing.DeleteIssuanceTemplateRequest{ID: *id}); err != nil {
+		return framework.NewRequestError(
+			util.LoggingErrorMsgf(err, "could not delete issuance template with id: %s", *id), http.StatusInternalServerError)
+	}
+
+	return framework.Respond(ctx, w, nil, http.StatusOK)
 }
 
 type ListIssuanceTemplatesResponse struct {
@@ -75,7 +118,7 @@ type ListIssuanceTemplatesResponse struct {
 
 // ListIssuanceTemplates godoc
 // @Summary      Lists issuance templates
-// @Description  Lists all issuangce templates stored in this service.
+// @Description  Lists all issuance templates stored in this service.
 // @Tags         IssuingAPI
 // @Accept       json
 // @Produce      json
@@ -83,6 +126,14 @@ type ListIssuanceTemplatesResponse struct {
 // @Failure      400      {string}  string  "Bad request"
 // @Failure      500      {string}  string  "Internal server error"
 // @Router       /v1/manifests [get]
-func (ir IssuanceRouter) ListIssuanceTemplates(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
-	return nil
+func (ir IssuanceRouter) ListIssuanceTemplates(ctx context.Context, w http.ResponseWriter, _ *http.Request) error {
+	gotManifests, err := ir.service.ListIssuanceTemplates(&issuing.ListIssuanceTemplatesRequest{})
+
+	if err != nil {
+		return framework.NewRequestError(
+			util.LoggingErrorMsg(err, "could not get templates"), http.StatusBadRequest)
+	}
+
+	resp := ListIssuanceTemplatesResponse{IssuanceTemplates: gotManifests.IssuanceTemplates}
+	return framework.Respond(ctx, w, resp, http.StatusOK)
 }
diff --git a/pkg/service/issuing/model.go b/pkg/service/issuing/model.go
index eebe45c..af5fb82 100644
--- a/pkg/service/issuing/model.go
+++ b/pkg/service/issuing/model.go
@@ -3,10 +3,7 @@ package issuing
 import (
 	"time"
 
-	"github.com/pkg/errors"
-	"github.com/tbd54566975/ssi-service/config"
-	"github.com/tbd54566975/ssi-service/pkg/service/framework"
-	"github.com/tbd54566975/ssi-service/pkg/storage"
+	"github.com/TBD54566975/ssi-sdk/util"
 	"go.einride.tech/aip/filtering"
 )
 
@@ -15,7 +12,7 @@ type GetIssuanceTemplateRequest struct {
 }
 
 type CredentialTemplateData struct {
-	// ID of the input descriptor in the application. Correponds to one of the
+	// Optional. When present, it's the ID of the input descriptor in the application. Corresponds to one of the
 	// PresentationDefinition.InputDescriptors[].ID in the credential manifest.
 	CredentialInputDescriptor string `json:"credentialInputDescriptor"`
 
@@ -25,10 +22,10 @@ type CredentialTemplateData struct {
 
 type TimeLike struct {
 	// For fixed time in the future.
-	*time.Time
+	Time *time.Time
 
 	// For a fixed offset from when it was issued.
-	*time.Duration
+	Duration *time.Duration
 }
 
 type ClaimTemplates struct {
@@ -55,11 +52,14 @@ type CredentialTemplate struct {
 }
 
 type IssuanceTemplate struct {
+	// ID of this template.
+	ID string `json:"id"`
+
 	// ID of the credential manifest that this template corresponds to.
-	CredentialManifest string `json:"credentialManifest"`
+	CredentialManifest string `json:"credentialManifest" validate:"required"`
 
 	// ID of the issuer that will be issuing the credentials.
-	Issuer string `json:"issuer"`
+	Issuer string `json:"issuer" validate:"required"`
 
 	// Info required to create a credential from a credential application.
 	Credentials []CredentialTemplate `json:"credentials"`
@@ -67,18 +67,18 @@ type IssuanceTemplate struct {
 
 type GetIssuanceTemplateResponse struct {
 	// The template that was requested.
-	IssuanceTemplate IssuanceTemplate `json:"issuanceTemplate"`
+	IssuanceTemplate *IssuanceTemplate `json:"issuanceTemplate"`
 }
 
 type CreateIssuanceTemplateRequest struct {
-	// ID to be used when creating the issuance template. Must be unique.
-	// Required.
-	ID string `json:"id" validate:"required"`
-
 	// The template to create.
 	IssuanceTemplate IssuanceTemplate `json:"issuanceTemplate"`
 }
 
+func (r CreateIssuanceTemplateRequest) IsValid() bool {
+	return util.IsValidStruct(r) == nil
+}
+
 type DeleteIssuanceTemplateRequest struct {
 	// ID of the template that will be deleted.
 	// Required.
@@ -90,60 +90,11 @@ type ListIssuanceTemplatesRequest struct {
 	Filter filtering.Filter
 }
 
+func (r ListIssuanceTemplatesRequest) Validate() error {
+	return util.NewValidator().Struct(r)
+}
+
 type ListIssuanceTemplatesResponse struct {
 	// The issuance templates that satisfy the query conditions.
 	IssuanceTemplates []IssuanceTemplate `json:"issuanceTemplates"`
 }
-
-type Service struct {
-	config  config.IssuingServiceConfig
-	storage Storage
-}
-
-func (s *Service) Type() framework.Type {
-	return framework.Issuing
-}
-
-func (s *Service) Status() framework.Status {
-	return framework.Status{Status: framework.StatusReady}
-}
-
-func NewIssuingService(config config.IssuingServiceConfig, s storage.ServiceStorage) (*Service, error) {
-	issuingStorage, err := NewIssuingStorage(s)
-	if err != nil {
-		return nil, errors.Wrap(err, "creating issuing storage")
-	}
-	return &Service{
-		storage: *issuingStorage,
-		config:  config,
-	}, nil
-}
-
-type Storage struct {
-	db storage.ServiceStorage
-}
-
-func NewIssuingStorage(s storage.ServiceStorage) (*Storage, error) {
-	if s == nil {
-		return nil, errors.New("s cannot be nil")
-	}
-	return &Storage{
-		db: s,
-	}, nil
-}
-
-func (s *Service) GetIssuanceTemplate(request *GetIssuanceTemplateRequest) (*GetIssuanceTemplateResponse, error) {
-	return nil, nil
-}
-
-func (s *Service) CreateIssuanceTemplate(request *CreateIssuanceTemplateRequest) (*IssuanceTemplate, error) {
-	return nil, nil
-}
-
-func (s *Service) DeleteIssuanceTemplate(request *DeleteIssuanceTemplateRequest) error {
-	return nil
-}
-
-func (s *Service) ListIssuanceTemplates(request *ListIssuanceTemplatesRequest) (*ListIssuanceTemplatesResponse, error) {
-	return nil, nil
-}
diff --git a/pkg/service/issuing/service.go b/pkg/service/issuing/service.go
new file mode 100644
index 0000000..2cc223c
--- /dev/null
+++ b/pkg/service/issuing/service.go
@@ -0,0 +1,121 @@
+package issuing
+
+import (
+	"github.com/google/uuid"
+	"github.com/pkg/errors"
+	"github.com/tbd54566975/ssi-service/config"
+	"github.com/tbd54566975/ssi-service/pkg/service/framework"
+	manifeststg "github.com/tbd54566975/ssi-service/pkg/service/manifest/storage"
+	"github.com/tbd54566975/ssi-service/pkg/service/schema"
+	"github.com/tbd54566975/ssi-service/pkg/storage"
+)
+
+type Service struct {
+	config          config.IssuingServiceConfig
+	storage         Storage
+	manifestStorage manifeststg.Storage
+	schemaStorage   schema.Storage
+}
+
+func NewIssuingService(config config.IssuingServiceConfig, s storage.ServiceStorage) (*Service, error) {
+	issuingStorage, err := NewIssuingStorage(s)
+	if err != nil {
+		return nil, errors.Wrap(err, "creating issuing storage")
+	}
+	manifestStorage, err := manifeststg.NewManifestStorage(s)
+	if err != nil {
+		return nil, errors.Wrap(err, "creating manifest storage")
+	}
+	schemaStorage, err := schema.NewSchemaStorage(s)
+	if err != nil {
+		return nil, errors.Wrap(err, "creating manifest storage")
+	}
+	return &Service{
+		storage:         *issuingStorage,
+		config:          config,
+		manifestStorage: *manifestStorage,
+		schemaStorage:   *schemaStorage,
+	}, nil
+}
+
+func (s *Service) GetIssuanceTemplate(request *GetIssuanceTemplateRequest) (*GetIssuanceTemplateResponse, error) {
+	storedIssuanceTemplate, err := s.storage.GetIssuanceTemplate(request.ID)
+	if err != nil {
+		return nil, errors.Wrapf(err, "getting issuance template with id: %s", request.ID)
+	}
+	if storedIssuanceTemplate == nil {
+		return nil, errors.Errorf("issuance template with id<%s> not be found", request.ID)
+	}
+	return &GetIssuanceTemplateResponse{
+		IssuanceTemplate: serviceModel(*storedIssuanceTemplate)}, nil
+}
+
+func (s *Service) CreateIssuanceTemplate(request *CreateIssuanceTemplateRequest) (*IssuanceTemplate, error) {
+	if !request.IsValid() {
+		return nil, errors.New("invalid create issuance template request")
+	}
+
+	for i, c := range request.IssuanceTemplate.Credentials {
+		if c.Expiry.Time != nil && c.Expiry.Duration != nil {
+			return nil, errors.Errorf("Time and Duration cannot be both set simultaneously at index %d", i)
+		}
+		if c.ID == "" {
+			return nil, errors.Errorf("ID cannot be empty at index %d", i)
+		}
+		if c.Schema != "" {
+			if _, err := s.schemaStorage.GetSchema(c.Schema); err != nil {
+				return nil, errors.Wrapf(err, "getting schema at index %d", i)
+			}
+		}
+	}
+
+	if _, err := s.manifestStorage.GetManifest(request.IssuanceTemplate.CredentialManifest); err != nil {
+		return nil, errors.Wrap(err, "getting manifest")
+	}
+
+	storedTemplate := StoredIssuanceTemplate{
+		IssuanceTemplate: request.IssuanceTemplate,
+	}
+	storedTemplate.IssuanceTemplate.ID = uuid.NewString()
+
+	if err := s.storage.StoreIssuanceTemplate(storedTemplate); err != nil {
+		return nil, errors.Wrap(err, "storing issuance template")
+	}
+
+	return serviceModel(storedTemplate), nil
+}
+
+func serviceModel(template StoredIssuanceTemplate) *IssuanceTemplate {
+	return &template.IssuanceTemplate
+}
+
+func (s *Service) DeleteIssuanceTemplate(request *DeleteIssuanceTemplateRequest) error {
+	if err := s.storage.DeleteIssuanceTemplate(request.ID); err != nil {
+		return errors.Wrap(err, "deleting template from storage")
+	}
+	return nil
+}
+
+func (s *Service) ListIssuanceTemplates(request *ListIssuanceTemplatesRequest) (*ListIssuanceTemplatesResponse, error) {
+	if err := request.Validate(); err != nil {
+		return nil, errors.Wrap(err, "invalid request")
+	}
+
+	ops, err := s.storage.ListIssuanceTemplates()
+	if err != nil {
+		return nil, errors.Wrap(err, "fetching ops from storage")
+	}
+
+	resp := &ListIssuanceTemplatesResponse{
+		IssuanceTemplates: ops,
+	}
+	return resp, nil
+}
+
+func (s *Service) Type() framework.Type {
+	return framework.Issuing
+}
+
+func (s *Service) Status() framework.Status {
+	return framework.Status{Status: framework.StatusReady}
+}
diff --git a/pkg/service/issuing/storage.go b/pkg/service/issuing/storage.go
new file mode 100644
index 0000000..0111992
--- /dev/null
+++ b/pkg/service/issuing/storage.go
@@ -0,0 +1,81 @@
+package issuing
+
+import (
+	"github.com/goccy/go-json"
+	"github.com/pkg/errors"
+	"github.com/tbd54566975/ssi-service/pkg/storage"
+)
+
+type Storage struct {
+	db storage.ServiceStorage
+}
+
+const namespace = "issuance_template"
+
+func NewIssuingStorage(s storage.ServiceStorage) (*Storage, error) {
+	if s == nil {
+		return nil, errors.New("s cannot be nil")
+	}
+	return &Storage{
+		db: s,
+	}, nil
+}
+
+type StoredIssuanceTemplate struct {
+	IssuanceTemplate IssuanceTemplate
+}
+
+func (s Storage) StoreIssuanceTemplate(template StoredIssuanceTemplate) error {
+	if template.IssuanceTemplate.ID == "" {
+		return errors.New("cannot store issuance template without an ID")
+	}
+	data, err := json.Marshal(template)
+	if err != nil {
+		return errors.Wrap(err, "marshalling template")
+	}
+	return s.db.Write(namespace, template.IssuanceTemplate.ID, data)
+}
+
+func (s Storage) GetIssuanceTemplate(id string) (*StoredIssuanceTemplate, error) {
+	if id == "" {
+		return nil, errors.New("cannot fetch issuance template without an ID")
+	}
+	data, err := s.db.Read(namespace, id)
+	if err != nil {
+		return nil, errors.Wrap(err, "reading from db")
+	}
+	if len(data) == 0 {
+		return nil, errors.Errorf("issuance template not found with id: %s", id)
+	}
+	var st StoredIssuanceTemplate
+	if err = json.Unmarshal(data, &st); err != nil {
+		return nil, errors.Wrap(err, "unmarshalling template")
+	}
+	return &st, nil
+}
+
+func (s Storage) DeleteIssuanceTemplate(id string) error {
+	if id == "" {
+		return nil
+	}
+	if err := s.db.Delete(namespace, id); err != nil {
+		return errors.Wrap(err, "deleting from db")
+	}
+	return nil
+}
+
+func (s Storage) ListIssuanceTemplates() ([]IssuanceTemplate, error) {
+	m, err := s.db.ReadAll(namespace)
+	if err != nil {
+		return nil, errors.Wrap(err, "reading all")
+	}
+	ts := make([]IssuanceTemplate, len(m))
+	i := 0
+	for k, v := range m {
+		if err = json.Unmarshal(v, &ts[i]); err != nil {
+			return nil, errors.Wrapf(err, "unmarshalling template with key <%s>", k)
+		}
+		i++
+	}
+	return ts, nil
+}


Test patch:
diff --git a/pkg/server/server_issuance_test.go b/pkg/server/server_issuance_test.go
new file mode 100644
index 0000000..8eea0f5
--- /dev/null
+++ b/pkg/server/server_issuance_test.go
@@ -0,0 +1,472 @@
+package server
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/TBD54566975/ssi-sdk/credential/exchange"
+	manifestsdk "github.com/TBD54566975/ssi-sdk/credential/manifest"
+	"github.com/TBD54566975/ssi-sdk/crypto"
+	"github.com/goccy/go-json"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"github.com/stretchr/testify/assert"
+	"github.com/tbd54566975/ssi-service/config"
+	"github.com/tbd54566975/ssi-service/pkg/server/router"
+	"github.com/tbd54566975/ssi-service/pkg/service/did"
+	"github.com/tbd54566975/ssi-service/pkg/service/issuing"
+	"github.com/tbd54566975/ssi-service/pkg/service/manifest/model"
+	"github.com/tbd54566975/ssi-service/pkg/service/schema"
+	"github.com/tbd54566975/ssi-service/pkg/storage"
+)
+
+func TestIssuanceRouter(t *testing.T) {
+	now := time.Now()
+	duration := 10 * time.Second
+	t.Run("CreateIssuanceTemplate", func(t *testing.T) {
+		issuerResp, createdSchema, manifest, r := setupAllThings(t)
+		for _, tc := range []struct {
+			name    string
+			request router.CreateIssuanceTemplateRequest
+		}{
+			{
+				name: "returns a template with ID",
+				request: router.CreateIssuanceTemplateRequest{
+					IssuanceTemplate: issuing.IssuanceTemplate{
+						CredentialManifest: manifest.Manifest.ID,
+						Issuer:             issuerResp.DID.ID,
+						Credentials: []issuing.CredentialTemplate{
+							{
+								ID:     "output_descriptor_1",
+								Schema: createdSchema.Schema.ID,
+								Data: issuing.CredentialTemplateData{
+									Claims: issuing.ClaimTemplates{
+										Data: map[string]any{
+											"foo":   "bar",
+											"hello": "$.vcsomething.something",
+										},
+									},
+								},
+								Expiry: issuing.TimeLike{
+									Time: &now,
+								},
+							},
+						},
+					},
+				},
+			},
+			{
+				name: "returns a template with ID when schema is empty",
+				request: router.CreateIssuanceTemplateRequest{
+					IssuanceTemplate: issuing.IssuanceTemplate{
+						CredentialManifest: manifest.Manifest.ID,
+						Issuer:             issuerResp.DID.ID,
+						Credentials: []issuing.CredentialTemplate{
+							{
+								ID:     "output_descriptor_1",
+								Schema: "",
+								Data: issuing.CredentialTemplateData{
+									Claims: issuing.ClaimTemplates{
+										Data: map[string]any{
+											"foo":   "bar",
+											"hello": "$.vcsomething.something",
+										},
+									},
+								},
+								Expiry: issuing.TimeLike{
+									Time: &now,
+								},
+							},
+						},
+					},
+				},
+			},
+		} {
+			t.Run(tc.name, func(t *testing.T) {
+				value := newRequestValue(t, tc.request)
+				req := httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/issuancetemplates", value)
+				w := httptest.NewRecorder()
+
+				err := r.CreateIssuanceTemplate(newRequestContext(), w, req)
+				assert.NoError(t, err)
+
+				var resp issuing.IssuanceTemplate
+				assert.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
+				assert.NotEmpty(t, resp.ID)
+			})
+		}
+	})
+
+	t.Run("CreateIssuanceTemplate returns error", func(t *testing.T) {
+		issuerResp, createdSchema, manifest, r := setupAllThings(t)
+
+		for _, tc := range []struct {
+			name          string
+			request       router.CreateIssuanceTemplateRequest
+			expectedError string
+		}{
+			{
+				name: "when missing output_descriptor_id",
+				request: router.CreateIssuanceTemplateRequest{
+					IssuanceTemplate: issuing.IssuanceTemplate{
+						CredentialManifest: manifest.Manifest.ID,
+						Issuer:             issuerResp.DID.ID,
+						Credentials: []issuing.CredentialTemplate{
+							{
+								ID:     "",
+								Schema: createdSchema.Schema.ID,
+								Data: issuing.CredentialTemplateData{
+									Claims: issuing.ClaimTemplates{
+										Data: map[string]any{
+											"foo":   "bar",
+											"hello": "$.vcsomething.something",
+										},
+									},
+								},
+								Expiry: issuing.TimeLike{
+									Time: &now,
+								},
+							},
+						},
+					},
+				},
+				expectedError: "ID cannot be empty",
+			},
+			{
+				name: "when both times are set",
+				request: router.CreateIssuanceTemplateRequest{
+					IssuanceTemplate: issuing.IssuanceTemplate{
+						CredentialManifest: manifest.Manifest.ID,
+						Issuer:             issuerResp.DID.ID,
+						Credentials: []issuing.CredentialTemplate{
+							{
+								ID:     "output_descriptor_1",
+								Schema: createdSchema.Schema.ID,
+								Data: issuing.CredentialTemplateData{
+									Claims: issuing.ClaimTemplates{
+										Data: map[string]any{
+											"foo":   "bar",
+											"hello": "$.vcsomething.something",
+										},
+									},
+								},
+								Expiry: issuing.TimeLike{
+									Time:     &now,
+									Duration: &duration,
+								},
+							},
+						},
+					},
+				},
+				expectedError: "Time and Duration cannot be both set simultaneously",
+			},
+			{
+				name: "when credential schema does not exist",
+				request: router.CreateIssuanceTemplateRequest{
+					IssuanceTemplate: issuing.IssuanceTemplate{
+						CredentialManifest: manifest.Manifest.ID,
+						Issuer:             issuerResp.DID.ID,
+						Credentials: []issuing.CredentialTemplate{
+							{
+								ID:     "output_descriptor_1",
+								Schema: "fake schema",
+								Data: issuing.CredentialTemplateData{
+									Claims: issuing.ClaimTemplates{
+										Data: map[string]any{
+											"foo":   "bar",
+											"hello": "$.vcsomething.something",
+										},
+									},
+								},
+								Expiry: issuing.TimeLike{
+									Time: &now,
+								},
+							},
+						},
+					},
+				},
+				expectedError: "schema not found",
+			},
+			{
+				name: "when credential manifest ID is does not exist",
+				request: router.CreateIssuanceTemplateRequest{
+					IssuanceTemplate: issuing.IssuanceTemplate{
+						CredentialManifest: "fake manifest id",
+						Issuer:             issuerResp.DID.ID,
+						Credentials: []issuing.CredentialTemplate{
+							{
+								ID:     "output_descriptor_1",
+								Schema: createdSchema.ID,
+								Data: issuing.CredentialTemplateData{
+									Claims: issuing.ClaimTemplates{
+										Data: map[string]any{
+											"foo":   "bar",
+											"hello": "$.vcsomething.something",
+										},
+									},
+								},
+								Expiry: issuing.TimeLike{
+									Time: &now,
+								},
+							},
+						},
+					},
+				},
+				expectedError: "manifest not found",
+			},
+			{
+				name: "when issuer is empty",
+				request: router.CreateIssuanceTemplateRequest{
+					IssuanceTemplate: issuing.IssuanceTemplate{
+						CredentialManifest: manifest.Manifest.ID,
+						Credentials: []issuing.CredentialTemplate{
+							{
+								ID:     "output_descriptor_1",
+								Schema: createdSchema.ID,
+								Data: issuing.CredentialTemplateData{
+									Claims: issuing.ClaimTemplates{
+										Data: map[string]any{
+											"foo":   "bar",
+											"hello": "$.vcsomething.something",
+										},
+									},
+								},
+								Expiry: issuing.TimeLike{
+									Time: &now,
+								},
+							},
+						},
+					},
+				},
+				expectedError: "field validation error",
+			},
+		} {
+			t.Run(tc.name, func(t *testing.T) {
+
+				value := newRequestValue(t, tc.request)
+				req := httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/issuancetemplates", value)
+				w := httptest.NewRecorder()
+
+				err := r.CreateIssuanceTemplate(newRequestContext(), w, req)
+				assert.Error(t, err)
+				assert.Contains(t, err.Error(), tc.expectedError)
+
+			})
+
+		}
+	})
+
+	t.Run("Create, Get, Delete work as expected", func(t *testing.T) {
+		issuerResp, createdSchema, manifest, r := setupAllThings(t)
+
+		inputTemplate := issuing.IssuanceTemplate{
+			CredentialManifest: manifest.Manifest.ID,
+			Issuer:             issuerResp.DID.ID,
+			Credentials: []issuing.CredentialTemplate{
+				{
+					ID:     "output_descriptor_1",
+					Schema: createdSchema.Schema.ID,
+					Data: issuing.CredentialTemplateData{
+						Claims: issuing.ClaimTemplates{
+							Data: map[string]any{
+								"foo":   "bar",
+								"hello": "$.vcsomething.something",
+							},
+						},
+					},
+					Expiry: issuing.TimeLike{
+						Time: &now,
+					},
+				},
+			},
+		}
+		var issuanceTemplate issuing.IssuanceTemplate
+
+		{
+			request := router.CreateIssuanceTemplateRequest{
+				IssuanceTemplate: inputTemplate,
+			}
+			value := newRequestValue(t, request)
+			req := httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/issuancetemplates", value)
+			w := httptest.NewRecorder()
+
+			err := r.CreateIssuanceTemplate(newRequestContext(), w, req)
+			assert.NoError(t, err)
+
+			assert.NoError(t, json.NewDecoder(w.Body).Decode(&issuanceTemplate))
+			if diff := cmp.Diff(inputTemplate, issuanceTemplate, cmpopts.IgnoreFields(issuing.IssuanceTemplate{}, "ID")); diff != "" {
+				t.Errorf("IssuanceTemplate mismatch (-want +got):\n%s", diff)
+			}
+		}
+
+		{
+			value := newRequestValue(t, nil)
+			req := httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/issuancetemplates/"+issuanceTemplate.ID, value)
+			w := httptest.NewRecorder()
+			err := r.GetIssuanceTemplate(newRequestContextWithParams(map[string]string{"id": issuanceTemplate.ID}), w, req)
+			assert.NoError(t, err)
+
+			var getIssuanceTemplate issuing.IssuanceTemplate
+			assert.NoError(t, json.NewDecoder(w.Body).Decode(&getIssuanceTemplate))
+			if diff := cmp.Diff(issuanceTemplate, getIssuanceTemplate); diff != "" {
+				t.Errorf("IssuanceTemplate mismatch (-want +got):\n%s", diff)
+			}
+		}
+
+		{
+			value := newRequestValue(t, nil)
+			req := httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/issuancetemplates/"+issuanceTemplate.ID, value)
+			w := httptest.NewRecorder()
+			err := r.DeleteIssuanceTemplate(newRequestContextWithParams(map[string]string{"id": issuanceTemplate.ID}), w, req)
+			assert.NoError(t, err)
+		}
+
+		{
+			value := newRequestValue(t, nil)
+			req := httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/issuancetemplates/"+issuanceTemplate.ID, value)
+			w := httptest.NewRecorder()
+			err := r.GetIssuanceTemplate(newRequestContextWithParams(map[string]string{"id": issuanceTemplate.ID}), w, req)
+
+			assert.Error(t, err)
+			assert.Contains(t, err.Error(), "issuance template not found")
+		}
+	})
+
+	t.Run("GetIssuanceTemplate returns error for unknown ID", func(t *testing.T) {
+		s := setupTestDB(t)
+		r := testIssuanceRouter(t, s)
+
+		value := newRequestValue(t, nil)
+		req := httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/issuancetemplates/where-is-it", value)
+		w := httptest.NewRecorder()
+		err := r.GetIssuanceTemplate(newRequestContextWithParams(map[string]string{"id": "where-is-it"}), w, req)
+
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "issuance template not found")
+	})
+
+	t.Run("ListIssuanceTemplates returns empty when there aren't templates", func(t *testing.T) {
+		s := setupTestDB(t)
+		r := testIssuanceRouter(t, s)
+
+		value := newRequestValue(t, nil)
+		req := httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/issuancetemplates", value)
+		w := httptest.NewRecorder()
+		err := r.ListIssuanceTemplates(newRequestContext(), w, req)
+
+		assert.NoError(t, err)
+		var getIssuanceTemplate router.ListIssuanceTemplatesResponse
+		assert.NoError(t, json.NewDecoder(w.Body).Decode(&getIssuanceTemplate))
+		assert.Empty(t, getIssuanceTemplate.IssuanceTemplates)
+	})
+
+	t.Run("ListIssuanceTemplates returns all created templates", func(t *testing.T) {
+		issuerResp, createdSchema, manifest, r := setupAllThings(t)
+
+		createSimpleTemplate(t, manifest, issuerResp, createdSchema, now, r)
+		createSimpleTemplate(t, manifest, issuerResp, createdSchema, now, r)
+
+		value := newRequestValue(t, nil)
+		req := httptest.NewRequest(http.MethodGet, "https://ssi-service.com/v1/issuancetemplates", value)
+		w := httptest.NewRecorder()
+		err := r.ListIssuanceTemplates(newRequestContext(), w, req)
+
+		assert.NoError(t, err)
+		var getIssuanceTemplate router.ListIssuanceTemplatesResponse
+		assert.NoError(t, json.NewDecoder(w.Body).Decode(&getIssuanceTemplate))
+		assert.Len(t, getIssuanceTemplate.IssuanceTemplates, 2)
+	})
+}
+
+func createSimpleTemplate(t *testing.T, manifest *model.CreateManifestResponse, issuerResp *did.CreateDIDResponse, createdSchema *schema.CreateSchemaResponse, now time.Time, r *router.IssuanceRouter) {
+	{
+		request := router.CreateIssuanceTemplateRequest{
+			IssuanceTemplate: issuing.IssuanceTemplate{
+				CredentialManifest: manifest.Manifest.ID,
+				Issuer:             issuerResp.DID.ID,
+				Credentials: []issuing.CredentialTemplate{
+					{
+						ID:     "output_descriptor_1",
+						Schema: createdSchema.Schema.ID,
+						Data: issuing.CredentialTemplateData{
+							Claims: issuing.ClaimTemplates{
+								Data: map[string]any{
+									"foo":   "bar",
+									"hello": "$.vcsomething.something",
+								},
+							},
+						},
+						Expiry: issuing.TimeLike{
+							Time: &now,
+						},
+					},
+				},
+			},
+		}
+		value := newRequestValue(t, request)
+		req := httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/issuancetemplates", value)
+		w := httptest.NewRecorder()
+
+		err := r.CreateIssuanceTemplate(newRequestContext(), w, req)
+		assert.NoError(t, err)
+	}
+}
+
+func setupAllThings(t *testing.T) (*did.CreateDIDResponse, *schema.CreateSchemaResponse, *model.CreateManifestResponse, *router.IssuanceRouter) {
+	s := setupTestDB(t)
+
+	_, keyStoreSvc := testKeyStore(t, s)
+	didSvc := testDIDService(t, s, keyStoreSvc)
+	schemaSvc := testSchemaService(t, s, keyStoreSvc, didSvc)
+	credSvc := testCredentialService(t, s, keyStoreSvc, didSvc, schemaSvc)
+	_, manifestSvc := testManifest(t, s, keyStoreSvc, didSvc, credSvc)
+
+	issuerResp, err := didSvc.CreateDIDByMethod(did.CreateDIDRequest{
+		Method:  "key",
+		KeyType: crypto.Ed25519,
+	})
+	assert.NoError(t, err)
+
+	licenseSchema := map[string]any{
+		"type": "object",
+		"properties": map[string]any{
+			"licenseType": map[string]any{
+				"type": "string",
+			},
+		},
+		"additionalProperties": true,
+	}
+	createdSchema, err := schemaSvc.CreateSchema(schema.CreateSchemaRequest{Author: issuerResp.DID.ID, Name: "license schema", Schema: licenseSchema, Sign: true})
+	assert.NoError(t, err)
+	sillyName := "some silly name"
+	manifest, err := manifestSvc.CreateManifest(model.CreateManifestRequest{
+		Name:      &sillyName,
+		IssuerDID: issuerResp.DID.ID,
+		ClaimFormat: &exchange.ClaimFormat{
+			JWT: &exchange.JWTType{Alg: []crypto.SignatureAlgorithm{crypto.EdDSA}},
+		},
+		OutputDescriptors: []manifestsdk.OutputDescriptor{
+			{
+				ID:     "output_descriptor_1",
+				Schema: createdSchema.Schema.ID,
+			},
+		},
+	})
+	assert.NoError(t, err)
+
+	r := testIssuanceRouter(t, s)
+	return issuerResp, createdSchema, manifest, r
+}
+
+func testIssuanceRouter(t *testing.T, s storage.ServiceStorage) *router.IssuanceRouter {
+	svc, err := issuing.NewIssuingService(config.IssuingServiceConfig{BaseServiceConfig: &config.BaseServiceConfig{
+		Name: "test-issuing",
+	}}, s)
+	assert.NoError(t, err)
+
+	r, err := router.NewIssuanceRouter(svc)
+	assert.NoError(t, err)
+	return r
+}