Repository: amp-labs/connectors
Pull request #1392 (2025-02-24 10:22:00)

[ENG-1941] feat: add netsuite proxy & metadata connector 
## Catalog variables
- Account ID (workspace)

## Metadata testing
### Installation
<img width="1114" alt="Screenshot 2025-02-24 at 4 01 51 PM" src="https://github.com/user-attachments/assets/58871e90-29c9-4cf2-9b16-17f30d223c72" />
<img width="981" alt="Screenshot 2025-02-24 at 4 18 45 PM" src="https://github.com/user-attachments/assets/05494f5d-a36e-4415-8379-5e78246b4ad9" />
<img width="724" alt="Screenshot 2025-02-24 at 4 18 56 PM" src="https://github.com/user-attachments/assets/67701427-efe1-445e-b12a-63011a625434" />

### Object metadata (script in `test/netsuite/metadata`)
<img width="1544" alt="Screenshot 2025-02-24 at 6 52 49 PM" src="https://github.com/user-attachments/assets/2b67a6ae-989d-4a58-b74c-2e318fbe8802" />

## Proxy testing
### GET
<img width="1049" alt="Screenshot 2025-02-24 at 6 43 35 PM" src="https://github.com/user-attachments/assets/4225a331-932c-4d85-a023-a87a77ae7e20" />

### POST
<img width="1004" alt="Screenshot 2025-02-24 at 6 44 01 PM" src="https://github.com/user-attachments/assets/ee4e7d6f-e494-494f-ade6-6f10d85790ac" />

### DELETE
<img width="1242" alt="Screenshot 2025-02-24 at 6 45 20 PM" src="https://github.com/user-attachments/assets/8f473e6e-e444-42c3-ace0-1cd12853e976" />

## Pagination
<img width="982" alt="Screenshot 2025-02-24 at 6 47 14 PM" src="https://github.com/user-attachments/assets/c990afc8-38a9-46be-a983-fe49e06ef1ee" />

## Console operations
<img width="1414" alt="Screenshot 2025-02-24 at 6 56 13 PM" src="https://github.com/user-attachments/assets/58782d38-4e13-4a7d-b1d7-2f71168e86b1" />

RajatPawar: > I think `parseObjectMetadataResponse` can be improved. Let me know if my suggestion works in practice.
> 
> Question: why proxy is combined with metadata impl?

We want to get this connector out ASAP

Patch:
diff --git a/common/json.go b/common/json.go
index c482a048..2e0184d1 100644
--- a/common/json.go
+++ b/common/json.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"mime"
 	"net/http"
+	"regexp"
 	"strings"
 
 	"github.com/spyzhov/ajson"
@@ -128,20 +129,10 @@ func ParseJSONResponse(res *http.Response, body []byte) (*JSONHTTPResponse, erro
 			body:      nil,
 		}, nil
 	}
-	// Ensure the response is JSON
-	ct := res.Header.Get("Content-Type")
-	if len(ct) > 0 {
-		mimeType, _, err := mime.ParseMediaType(ct)
-		if err != nil {
-			return nil, fmt.Errorf("failed to parse content type: %w", err)
-		}
 
-		// Providers implementing JSONAPISpeicifcations returns application/vnd.api+json
-		if mimeType != "application/json" && mimeType != "application/vnd.api+json" {
-			return nil, fmt.Errorf("%w: expected content type to be application/json or application/vnd.api+json , got %s",
-				ErrNotJSON, mimeType,
-			)
-		}
+	// Ensure the response is JSON
+	if err := EnsureContentType(`^application/.*json$`, res, false); err != nil {
+		return nil, err
 	}
 
 	// Unmarshall the response body into JSON
@@ -197,3 +188,42 @@ func AddSuffixIfNotExists(str string, suffix string) string {
 
 	return str
 }
+
+var ErrMissingContentType = errors.New("missing content type")
+
+// EnsureContentType ensures that the content type matches the given pattern.
+// If errOnMissing is true, an error is returned if the content type is missing.
+// Otherwise, the function returns nil.
+func EnsureContentType(pattern string, res *http.Response, errOnMissing bool) error {
+	ctype := res.Header.Get("Content-Type")
+
+	// If the content type is missing, return an error if errOnMissing is true,
+	// otherwise return nil.
+	if len(ctype) == 0 {
+		if errOnMissing {
+			return fmt.Errorf("%w: expected content type to be application/*json", ErrMissingContentType)
+		}
+
+		return nil
+	}
+
+	mimeType, _, err := mime.ParseMediaType(ctype)
+	if err != nil {
+		return fmt.Errorf("failed to parse content type: %w", err)
+	}
+
+	// Check if the mimeType follows the pattern application/*json (e.g.
+	// application/vnd.api+json, application/schema+json, etc.)
+	re, err := regexp.Compile(pattern)
+	if err != nil {
+		return fmt.Errorf("failed to compile regex: %w", err)
+	}
+
+	if !re.MatchString(mimeType) {
+		return fmt.Errorf("%w: expected content type to be %s, got %s",
+			ErrNotJSON, pattern, mimeType,
+		)
+	}
+
+	return nil
+}
diff --git a/providers/netsuite.go b/providers/netsuite.go
new file mode 100644
index 00000000..d4764561
--- /dev/null
+++ b/providers/netsuite.go
@@ -0,0 +1,42 @@
+package providers
+
+const Netsuite Provider = "netsuite"
+
+// nolint:lll
+func init() {
+	SetInfo(Netsuite, ProviderInfo{
+		DisplayName: "Netsuite",
+		AuthType:    Oauth2,
+		BaseURL:     "https://{{.workspace}}.suitetalk.api.netsuite.com",
+		Oauth2Opts: &Oauth2Opts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://{{.workspace}}.app.netsuite.com/app/login/oauth2/authorize.nl",
+			TokenURL:                  "https://{{.workspace}}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: true,
+			DocsURL:                   "https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771733782.html#procedure_157838925981",
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+		Media: &Media{
+			DarkMode: &MediaTypeDarkMode{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1740403711/media/netsuite_1740403705.jpg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1740411027/netsuite_xtpygf.svg",
+			},
+			Regular: &MediaTypeRegular{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1740403711/media/netsuite_1740403705.jpg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1740404009/media/netsuite_1740403997.svg",
+			},
+		},
+	})
+}
diff --git a/providers/netsuite/connector.go b/providers/netsuite/connector.go
new file mode 100644
index 00000000..61a68058
--- /dev/null
+++ b/providers/netsuite/connector.go
@@ -0,0 +1,49 @@
+package netsuite
+
+import (
+	_ "embed"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/components/operations"
+	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/providers"
+)
+
+const apiVersion = "v1"
+
+type Connector struct {
+	// Basic connector
+	*components.Connector
+
+	// Require authenticated client & account
+	common.RequireAuthenticatedClient
+	common.RequireWorkspace
+
+	// Supported operations
+	components.SchemaProvider
+}
+
+// API Reference: https://td2972271.app.netsuite.com/app/help/helpcenter.nl?fid=section_158151234003.html
+// NewConnector.
+func NewConnector(params common.Parameters) (*Connector, error) {
+	// Create base connector with provider info
+	return components.Initialize(providers.Netsuite, params, constructor)
+}
+
+func constructor(base *components.Connector) (*Connector, error) {
+	connector := &Connector{Connector: base}
+
+	// Set the metadata provider for the connector
+	connector.SchemaProvider = schema.NewObjectSchemaProvider(
+		connector.HTTPClient().Client,
+		schema.FetchModeParallel,
+		operations.SingleObjectMetadataHandlers{
+			BuildRequest:  connector.buildObjectMetadataRequest,
+			ParseResponse: connector.parseObjectMetadataResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
+	return connector, nil
+}
diff --git a/providers/netsuite/handlers.go b/providers/netsuite/handlers.go
new file mode 100644
index 00000000..b457bd4c
--- /dev/null
+++ b/providers/netsuite/handlers.go
@@ -0,0 +1,77 @@
+package netsuite
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+)
+
+func (c *Connector) buildObjectMetadataRequest(ctx context.Context, object string) (*http.Request, error) {
+	url, err := urlbuilder.New(c.ProviderInfo().BaseURL, "services/rest/record", apiVersion, "metadata-catalog", object)
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
+	if err != nil {
+		return nil, err
+	}
+
+	// This tells the netsuite API to give us a shortened JSON schema for the properties.
+	// Passing in schema+json, swagger+json, etc, will give us more information, but it's not
+	// needed right now.
+	req.Header.Add("Accept", "application/schema+json")
+
+	return req, nil
+}
+
+// Example of a raw response is in test/netsuite/metadata/example.json.
+func (c *Connector) parseObjectMetadataResponse(
+	ctx context.Context,
+	object string,
+	resp *common.JSONHTTPResponse,
+) (*common.ObjectMetadata, error) {
+	metadata, err := common.UnmarshalJSON[metadataResponse](resp)
+	if err != nil {
+		return nil, err
+	}
+
+	if metadata == nil || metadata.Type != "object" || metadata.Properties == nil {
+		return nil, fmt.Errorf("%w: invalid metadata response: %+v", common.ErrMissingExpectedValues, metadata)
+	}
+
+	result := &common.ObjectMetadata{
+		DisplayName: object,
+		Fields:      make(map[string]common.FieldMetadata),
+		FieldsMap:   make(map[string]string),
+	}
+
+	for field, metadata := range metadata.Properties {
+		if metadata.Type == "object" {
+			continue // Skip nested associated objects
+		}
+
+		title := field
+		if metadata.Title != "" {
+			title = metadata.Title
+		}
+
+		format := metadata.Type
+		if metadata.Format != "" {
+			format = metadata.Format
+		}
+
+		result.Fields[field] = common.FieldMetadata{
+			DisplayName:  title,
+			ProviderType: format,
+		}
+
+		// Deprecated: this map includes only display names.
+		result.FieldsMap[field] = title
+	}
+
+	return result, nil
+}
diff --git a/providers/netsuite/types.go b/providers/netsuite/types.go
new file mode 100644
index 00000000..10cafc00
--- /dev/null
+++ b/providers/netsuite/types.go
@@ -0,0 +1,20 @@
+package netsuite
+
+// metadataResponse is the response from the /metadata-catalog/{object} endpoint.
+type metadataResponse struct {
+	// The type of the object.
+	Type string `json:"type"`
+
+	// Contains key-value pairs of field names and their metadata.
+	// Metadata can have title, type,description and nullable, even properties again.
+	Properties map[string]fieldMetadata `json:"properties"`
+
+	// The fields that the object can be filtered by.
+	Filterable []string `json:"x-ns-filterable"` // nolint:tagliatelle
+}
+
+type fieldMetadata struct {
+	Title  string `json:"title"`
+	Type   string `json:"type"`   // string, number, boolean, object, array, null
+	Format string `json:"format"` // date-time, date, etc.
+}


Test patch:
diff --git a/common/json_test.go b/common/json_test.go
index 8db23c26..74c266ba 100644
--- a/common/json_test.go
+++ b/common/json_test.go
@@ -1,6 +1,9 @@
 package common
 
 import (
+	"bytes"
+	"io"
+	"net/http"
 	"testing"
 
 	"github.com/amp-labs/connectors/test/utils/testutils"
@@ -17,12 +20,14 @@ func TestUnmarshalJSON(t *testing.T) { // nolint:funlen
 	tests := []struct {
 		name        string
 		input       []byte
+		contentType string
 		expected    *Story
 		expectedErr error
 	}{
 		{
-			name:  "Nil body produces empty struct",
-			input: nil,
+			name:        "Nil body produces empty struct",
+			input:       nil,
+			contentType: "application/json",
 			expected: &Story{
 				Title:       "",
 				Description: "",
@@ -30,8 +35,9 @@ func TestUnmarshalJSON(t *testing.T) { // nolint:funlen
 			expectedErr: nil,
 		},
 		{
-			name:  "Empty body produces empty struct",
-			input: []byte(""),
+			name:        "Empty body produces empty struct",
+			input:       []byte(""),
+			contentType: "application/json",
 			expected: &Story{
 				Title:       "",
 				Description: "",
@@ -41,12 +47,58 @@ func TestUnmarshalJSON(t *testing.T) { // nolint:funlen
 		{
 			name:        "Invalid JSON produces marshal error",
 			input:       []byte("2359"),
+			contentType: "application/json",
 			expected:    nil,
 			expectedErr: ErrFailedToUnmarshalBody,
 		},
 		{
-			name:  "Valid JSON values are mapped to struct fields",
-			input: []byte(`{"title": "Amazing", "description": "very long story"}`),
+			name:        "Valid JSON values are mapped to struct fields",
+			input:       []byte(`{"title": "Amazing", "description": "very long story"}`),
+			contentType: "application/json",
+			expected: &Story{
+				Title:       "Amazing",
+				Description: "very long story",
+			},
+			expectedErr: nil,
+		},
+		{
+			name:        "Valid JSON with application/vnd.api+json content type",
+			input:       []byte(`{"title": "Amazing", "description": "very long story"}`),
+			contentType: "application/vnd.api+json",
+			expected: &Story{
+				Title:       "Amazing",
+				Description: "very long story",
+			},
+			expectedErr: nil,
+		},
+		{
+			name:        "Valid JSON with application/schema+json content type",
+			input:       []byte(`{"title": "Amazing", "description": "very long story"}`),
+			contentType: "application/schema+json",
+			expected: &Story{
+				Title:       "Amazing",
+				Description: "very long story",
+			},
+			expectedErr: nil,
+		},
+		{
+			name:        "Invalid content type text/plain",
+			input:       []byte(`{"title": "Amazing", "description": "very long story"}`),
+			contentType: "text/plain",
+			expected:    nil,
+			expectedErr: ErrNotJSON,
+		},
+		{
+			name:        "Invalid content type application/xml",
+			input:       []byte(`{"title": "Amazing", "description": "very long story"}`),
+			contentType: "application/xml",
+			expected:    nil,
+			expectedErr: ErrNotJSON,
+		},
+		{
+			name:        "Empty content type, assume application/json and try to unmarshal",
+			input:       []byte(`{"title": "Amazing", "description": "very long story"}`),
+			contentType: "",
 			expected: &Story{
 				Title:       "Amazing",
 				Description: "very long story",
@@ -55,15 +107,27 @@ func TestUnmarshalJSON(t *testing.T) { // nolint:funlen
 		},
 	}
 
-	for _, tt := range tests {
+	for _, ttc := range tests {
 		// nolint:varnamelen
-		t.Run(tt.name, func(t *testing.T) {
+		t.Run(ttc.name, func(t *testing.T) {
 			t.Parallel()
 
-			output, err := UnmarshalJSON[Story](&JSONHTTPResponse{
-				bodyBytes: tt.input,
-			})
-			testutils.CheckOutputWithError(t, tt.name, tt.expected, tt.expectedErr, output, err)
+			resp := &http.Response{
+				Header: http.Header{
+					"Content-Type": []string{ttc.contentType},
+				},
+				Body: io.NopCloser(bytes.NewReader(ttc.input)),
+			}
+
+			output, err := ParseJSONResponse(resp, ttc.input)
+			if err != nil {
+				testutils.CheckErrors(t, ttc.name, []error{ttc.expectedErr}, err)
+
+				return
+			}
+
+			story, outErr := UnmarshalJSON[Story](output)
+			testutils.CheckOutputWithError(t, ttc.name, ttc.expected, ttc.expectedErr, story, outErr)
 		})
 	}
 }
diff --git a/test/netsuite/connector.go b/test/netsuite/connector.go
new file mode 100644
index 00000000..335236be
--- /dev/null
+++ b/test/netsuite/connector.go
@@ -0,0 +1,62 @@
+package netsuite
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/scanning/credscanning"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/netsuite"
+	"github.com/amp-labs/connectors/test/utils"
+	"golang.org/x/oauth2"
+)
+
+func GetNetsuiteConnector(ctx context.Context) *netsuite.Connector {
+	filePath := credscanning.LoadPath(providers.Netsuite)
+	reader := utils.MustCreateProvCredJSON(filePath, true, false)
+
+	options := []common.OAuthOption{
+		common.WithOAuthClient(http.DefaultClient),
+		common.WithOAuthConfig(getConfig(nil)),
+		common.WithOAuthToken(&oauth2.Token{
+			AccessToken:  reader.Get(credscanning.Fields.AccessToken),
+			RefreshToken: reader.Get(credscanning.Fields.RefreshToken),
+		}),
+	}
+
+	client, err := common.NewOAuthHTTPClient(ctx, options...)
+	if err != nil {
+		panic(err)
+	}
+
+	conn, err := netsuite.NewConnector(common.Parameters{
+		AuthenticatedClient: client,
+		Workspace:           reader.Get(credscanning.Fields.Workspace),
+	})
+	if err != nil {
+		utils.Fail("error creating netsuite connector", "error", err)
+	}
+
+	return conn
+}
+
+func getConfig(reader *credscanning.ProviderCredentials) *oauth2.Config {
+	cfg := &oauth2.Config{
+		ClientID:     reader.Get(credscanning.Fields.ClientId),
+		ClientSecret: reader.Get(credscanning.Fields.ClientSecret),
+		RedirectURL:  "https://api.withampersand.com/callbacks/v1/oauth",
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   "https://{{.workspace}}.app.netsuite.com/app/login/oauth2/authorize.nl",
+			TokenURL:  "https://{{.workspace}}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token",
+			AuthStyle: oauth2.AuthStyleInParams,
+		},
+		Scopes: []string{
+			"email",
+			"rest_webservices",
+			"suite_analytics",
+		},
+	}
+
+	return cfg
+}
diff --git a/test/netsuite/metadata/example.json b/test/netsuite/metadata/example.json
new file mode 100644
index 00000000..8e930b50
--- /dev/null
+++ b/test/netsuite/metadata/example.json
@@ -0,0 +1,489 @@
+{
+  "type": "object",
+  "properties": {
+      "isInactive": {
+          "title": "Contact is Inactive",
+          "type": "boolean",
+          "description": "When you check this box, this contact no longer appears on the Contacts list unless you check the Show Inactives box at the bottom of the page. Also, you can no longer select this contact from lists on transactions, company records, task records or events records."
+      },
+      "firstName": {
+          "title": "First Name",
+          "type": "string",
+          "description": "Enter the contact&apos;s name. What you enter here automatically appears first in the Contact field. This field is required for the Online Bill Pay feature.",
+          "nullable": true
+      },
+      "homePhone": {
+          "title": "Home Phone",
+          "type": "string",
+          "description": "Enter the contact&apos;s home phone number. This number appears only on this record.",
+          "nullable": true
+      },
+      "externalId": {
+          "title": "External ID",
+          "type": "string",
+          "nullable": true
+      },
+      "entityId": {
+          "title": "Entity ID",
+          "type": "string",
+          "description": "NetSuite automatically completes this field as you enter first, middle, and last names below. This is how the contact&apos;s name appears in all lists.",
+          "nullable": true
+      },
+      "title": {
+          "title": "Job Title",
+          "type": "string",
+          "description": "Enter this contact&apos;s title at his or her company. On the record for this contact&apos;s company, in the Contacts section, this title appears next to the contacts name.",
+          "nullable": true
+      },
+      "emailPreference": {
+          "type": "object",
+          "properties": {
+              "id": {
+                  "title": "Internal identifier",
+                  "type": "string",
+                  "enum": [
+                      "PDF",
+                      "HTML",
+                      "DEFAULT"
+                  ]
+              },
+              "refName": {
+                  "title": "Reference Name",
+                  "type": "string"
+              }
+          }
+      },
+      "unsubscribe": {
+          "title": "Unsubscribe from Campaigns",
+          "type": "boolean",
+          "description": "This box is checked if this contact has unsubscribed from your e-mail marketing campaigns. Unsubscribed contacts receive no marketing campaign e-mail. Contacts can unsubscribe to your e-mail marketing campaigns by clicking a link in any campaign e-mail they receive. To resubscribe to e-mail campaigns, a contact must opt in through the Customer Center or click the Unsubscribe link on a campaign e-mail message. If you are using the US Edition of NetSuite and you want new contacts to be subscribed by default, an administrator can go to Setup &gt; Marketing &gt; Set Up Marketing and clear the Unsubscribed to Marketing by Default box."
+      },
+      "mobilePhone": {
+          "title": "Mobile Phone",
+          "type": "string",
+          "description": "Enter the contact&apos;s mobile or cell phone number. This number appears only on this record.",
+          "nullable": true
+      },
+      "id": {
+          "title": "Internal ID",
+          "type": "string",
+          "description": "The internal ID for this record is shown here. If you do not want to show internal IDs, clear the Show Internal IDs box at Home &gt; Set Preferences.",
+          "nullable": true
+      },
+      "fax": {
+          "title": "Fax",
+          "type": "string",
+          "description": "Enter a fax number for this record. You should enter the fax number exactly as it must be dialed. If a &apos;1&apos; is required to fax to this number, be sure to include it at the beginning of the number. The number you enter automatically appears in the To Be Faxed field of transactions when you select this customer. To fax NetSuite forms, an administrator must first set up fax service at Setup &gt; Set Up Printing, Fax and E-mail &gt; Fax.",
+          "nullable": true
+      },
+      "assistantPhone": {
+          "title": "Assist. Phone",
+          "type": "string",
+          "description": "Enter the phone number for this contact&apos;s assistant. This number appears only on this record.",
+          "nullable": true
+      },
+      "email": {
+          "title": "Email",
+          "type": "string",
+          "description": "Enter the contact&apos;s e-mail address. If you enter an e-mail address, you can e-mail this contact directly from the Contacts list. Also, If you invite this contact to an event, the contact receives e-mail with the event details.",
+          "nullable": true
+      },
+      "owner": {
+          "type": "integer",
+          "format": "int64",
+          "nullable": true
+      },
+      "isPrivate": {
+          "title": "Private",
+          "type": "boolean",
+          "description": "Check this box if this is a private contact. Private contacts can only be viewed by the person that entered the contact record. They are also excluded from the Duplicate Detection process."
+      },
+      "comments": {
+          "title": "Comments",
+          "type": "string",
+          "description": "Enter any other information you want to note about this contact. These notes appears only on this record. You can enter up to 999 characters of text. If you see the Enhance text button next to this field, you can get writing help for this field from our generative AI service, NetSuite Text Enhance. Click the Enhance Text button to use the available options. Learn more about Text Enhance.",
+          "nullable": true
+      },
+      "lastModifiedDate": {
+          "title": "Last Modified Date",
+          "type": "string",
+          "format": "date-time",
+          "nullable": true
+      },
+      "middleName": {
+          "title": "Middle Name",
+          "type": "string",
+          "description": "Enter the contact&apos;s middle name or initial. This field is optional. What you enter here automatically appears second in the Contact field.",
+          "nullable": true
+      },
+      "dateCreated": {
+          "title": "Date Created",
+          "type": "string",
+          "description": "In this field, NetSuite automatically records the date you created this record.",
+          "format": "date-time",
+          "nullable": true
+      },
+      "globalSubscriptionStatus": {
+          "type": "object",
+          "properties": {
+              "id": {
+                  "title": "Internal identifier",
+                  "type": "string",
+                  "enum": [
+                      "1",
+                      "2",
+                      "3",
+                      "4"
+                  ]
+              },
+              "refName": {
+                  "title": "Reference Name",
+                  "type": "string"
+              }
+          }
+      },
+      "lastName": {
+          "title": "Last Name",
+          "type": "string",
+          "description": "Enter the contact&apos;s last name. What you enter here automatically appears last in the Contact field. This field is required for the Online Bill Pay feature.",
+          "nullable": true
+      },
+      "altEmail": {
+          "title": "Alt. Email",
+          "type": "string",
+          "description": "&lt;p&gt;Enter an alternate email address for this contact. If you use the Capture Email Replies, email sent with the alternate email address are automatically attached to the contact record.",
+          "nullable": true
+      },
+      "customForm": {
+          "type": "object",
+          "properties": {
+              "id": {
+                  "title": "Internal identifier",
+                  "type": "string",
+                  "enum": [
+                      "550",
+                      "-40",
+                      "350"
+                  ]
+              },
+              "refName": {
+                  "title": "Reference Name",
+                  "type": "string"
+              }
+          }
+      },
+      "phone": {
+          "title": "Main Phone",
+          "type": "string",
+          "description": "Enter the phone number you primarily use to reach this contact. This number appears in the Contacts list and in the Contact section of company records. This field is required for the Online Bill Pay feature.",
+          "nullable": true
+      },
+      "salutation": {
+          "title": "Mr./Ms...",
+          "type": "string",
+          "description": "Enter the contact&apos;s salutation. Examples are Mr., Mrs., Ms. and Miss.",
+          "nullable": true
+      },
+      "officePhone": {
+          "title": "Office Phone",
+          "type": "string",
+          "description": "Enter the contact&apos;s work phone number. This number appears only on this record.",
+          "nullable": true
+      },
+      "supervisorPhone": {
+          "title": "Sup. Phone",
+          "type": "string",
+          "description": "Enter the phone number for this contact&apos;s supervisor.",
+          "nullable": true
+      },
+      "refName": {
+          "title": "Reference Name",
+          "type": "string"
+      },
+      "links": {
+          "title": "Links",
+          "type": "array",
+          "readOnly": true,
+          "items": {
+              "$ref": "/services/rest/record/v1/metadata-catalog/nsLink"
+          }
+      },
+      "image": {
+          "type": "object",
+          "properties": {
+              "id": {
+                  "title": "Internal identifier",
+                  "type": "string"
+              },
+              "refName": {
+                  "title": "Reference Name",
+                  "type": "string"
+              },
+              "externalId": {
+                  "title": "External identifier",
+                  "type": "string"
+              },
+              "links": {
+                  "title": "Links",
+                  "type": "array",
+                  "readOnly": true,
+                  "items": {
+                      "$ref": "/services/rest/record/v1/metadata-catalog/nsLink"
+                  }
+              }
+          }
+      },
+      "contactCampaignEvent": {
+          "type": "object",
+          "properties": {
+              "id": {
+                  "title": "Internal identifier",
+                  "type": "string"
+              },
+              "refName": {
+                  "title": "Reference Name",
+                  "type": "string"
+              },
+              "externalId": {
+                  "title": "External identifier",
+                  "type": "string"
+              },
+              "links": {
+                  "title": "Links",
+                  "type": "array",
+                  "readOnly": true,
+                  "items": {
+                      "$ref": "/services/rest/record/v1/metadata-catalog/nsLink"
+                  }
+              }
+          }
+      },
+      "assistant": {
+          "type": "object",
+          "properties": {
+              "id": {
+                  "title": "Internal identifier",
+                  "type": "string"
+              },
+              "refName": {
+                  "title": "Reference Name",
+                  "type": "string"
+              },
+              "externalId": {
+                  "title": "External identifier",
+                  "type": "string"
+              },
+              "links": {
+                  "title": "Links",
+                  "type": "array",
+                  "readOnly": true,
+                  "items": {
+                      "$ref": "/services/rest/record/v1/metadata-catalog/nsLink"
+                  }
+              }
+          }
+      },
+      "company": {
+          "type": "object",
+          "properties": {
+              "id": {
+                  "title": "Internal identifier",
+                  "type": "string"
+              },
+              "refName": {
+                  "title": "Reference Name",
+                  "type": "string"
+              },
+              "externalId": {
+                  "title": "External identifier",
+                  "type": "string"
+              },
+              "links": {
+                  "title": "Links",
+                  "type": "array",
+                  "readOnly": true,
+                  "items": {
+                      "$ref": "/services/rest/record/v1/metadata-catalog/nsLink"
+                  }
+              }
+          }
+      },
+      "supervisor": {
+          "type": "object",
+          "properties": {
+              "id": {
+                  "title": "Internal identifier",
+                  "type": "string"
+              },
+              "refName": {
+                  "title": "Reference Name",
+                  "type": "string"
+              },
+              "externalId": {
+                  "title": "External identifier",
+                  "type": "string"
+              },
+              "links": {
+                  "title": "Links",
+                  "type": "array",
+                  "readOnly": true,
+                  "items": {
+                      "$ref": "/services/rest/record/v1/metadata-catalog/nsLink"
+                  }
+              }
+          }
+      },
+      "contactSourceCampaignCategory": {
+          "type": "object",
+          "properties": {
+              "id": {
+                  "title": "Internal identifier",
+                  "type": "string"
+              },
+              "refName": {
+                  "title": "Reference Name",
+                  "type": "string"
+              },
+              "externalId": {
+                  "title": "External identifier",
+                  "type": "string"
+              },
+              "links": {
+                  "title": "Links",
+                  "type": "array",
+                  "readOnly": true,
+                  "items": {
+                      "$ref": "/services/rest/record/v1/metadata-catalog/nsLink"
+                  }
+              }
+          }
+      },
+      "contactSource": {
+          "type": "object",
+          "properties": {
+              "id": {
+                  "title": "Internal identifier",
+                  "type": "string"
+              },
+              "refName": {
+                  "title": "Reference Name",
+                  "type": "string"
+              },
+              "externalId": {
+                  "title": "External identifier",
+                  "type": "string"
+              },
+              "links": {
+                  "title": "Links",
+                  "type": "array",
+                  "readOnly": true,
+                  "items": {
+                      "$ref": "/services/rest/record/v1/metadata-catalog/nsLink"
+                  }
+              }
+          }
+      },
+      "subsidiary": {
+          "type": "object",
+          "properties": {
+              "id": {
+                  "title": "Internal identifier",
+                  "type": "string"
+              },
+              "refName": {
+                  "title": "Reference Name",
+                  "type": "string"
+              },
+              "externalId": {
+                  "title": "External identifier",
+                  "type": "string"
+              },
+              "links": {
+                  "title": "Links",
+                  "type": "array",
+                  "readOnly": true,
+                  "items": {
+                      "$ref": "/services/rest/record/v1/metadata-catalog/nsLink"
+                  }
+              }
+          }
+      },
+      "category": {
+          "type": "object",
+          "properties": {
+              "links": {
+                  "title": "Links",
+                  "type": "array",
+                  "readOnly": true,
+                  "items": {
+                      "$ref": "/services/rest/record/v1/metadata-catalog/nsLink"
+                  }
+              },
+              "totalResults": {
+                  "title": "Total Results",
+                  "type": "integer",
+                  "format": "int64",
+                  "readOnly": true
+              },
+              "count": {
+                  "title": "Count",
+                  "type": "integer",
+                  "format": "int64",
+                  "readOnly": true
+              },
+              "hasMore": {
+                  "title": "Has More Results",
+                  "type": "boolean",
+                  "readOnly": true
+              },
+              "offset": {
+                  "title": "Query Offset",
+                  "type": "integer",
+                  "format": "int64",
+                  "readOnly": true
+              },
+              "items": {
+                  "type": "array",
+                  "items": {
+                      "$ref": "/services/rest/record/v1/metadata-catalog/contactCategory"
+                  }
+              }
+          }
+      }
+  },
+  "$schema": "http://json-schema.org/draft-06/hyper-schema#",
+  "x-ns-filterable": [
+      "isInactive",
+      "firstName",
+      "homePhone",
+      "externalId",
+      "contactSourceCampaignCategory",
+      "entityId",
+      "contactCampaignEvent",
+      "title",
+      "emailPreference",
+      "unsubscribe",
+      "mobilePhone",
+      "contactSource",
+      "company",
+      "id",
+      "fax",
+      "assistantPhone",
+      "email",
+      "isPrivate",
+      "comments",
+      "lastModifiedDate",
+      "assistant",
+      "middleName",
+      "dateCreated",
+      "globalSubscriptionStatus",
+      "lastName",
+      "altEmail",
+      "phone",
+      "salutation",
+      "officePhone",
+      "category",
+      "supervisor",
+      "supervisorPhone"
+  ]
+}
\ No newline at end of file
diff --git a/test/netsuite/metadata/main.go b/test/netsuite/metadata/main.go
new file mode 100644
index 00000000..ad969578
--- /dev/null
+++ b/test/netsuite/metadata/main.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os/signal"
+	"syscall"
+
+	connTest "github.com/amp-labs/connectors/test/netsuite"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+// We want to compare fields returned by read and schema properties provided by metadata methods.
+// Properties from read must all be present in schema definition.
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetNetsuiteConnector(ctx)
+
+	response, err := conn.ListObjectMetadata(ctx, []string{
+		"account",
+		"contact",
+		"salesorder",
+	})
+	if err != nil {
+		utils.Fail("error listing metadata for Netsuite", "error", err)
+	}
+
+	for obj, metadata := range response.Result {
+		fmt.Printf("Metadata for %s: %+v\n\n\n", obj, metadata)
+	}
+}