提交 b5d94d5f 编写于 作者: B bryk

Validate app name in the deploy form

This validates application name asynchronously on the server. Validation is synced with namespace select :)
上级 d7299118
......@@ -36,10 +36,16 @@ func CreateHttpApiHandler(client *client.Client) http.Handler {
To(apiHandler.handleDeploy).
Reads(AppDeploymentSpec{}).
Writes(AppDeploymentSpec{}))
deployWs.Route(
deployWs.POST("/validate/name").
To(apiHandler.handleNameValidity).
Reads(AppNameValiditySpec{}).
Writes(AppNameValidity{}))
wsContainer.Add(deployWs)
replicaSetWs := new(restful.WebService)
replicaSetWs.Path("/api/replicasets").
Consumes(restful.MIME_JSON).
Produces(restful.MIME_JSON)
replicaSetWs.Route(
replicaSetWs.GET("").
......@@ -117,6 +123,23 @@ func (apiHandler *ApiHandler) handleDeploy(request *restful.Request, response *r
response.WriteHeaderAndEntity(http.StatusCreated, appDeploymentSpec)
}
// Handles app name validation API call.
func (apiHandler *ApiHandler) handleNameValidity(request *restful.Request, response *restful.Response) {
spec := new(AppNameValiditySpec)
if err := request.ReadEntity(spec); err != nil {
handleInternalError(response, err)
return
}
validity, err := ValidateAppName(spec, apiHandler.client)
if err != nil {
handleInternalError(response, err)
return
}
response.WriteHeaderAndEntity(http.StatusCreated, validity)
}
// Handles get Replica Set list API call.
func (apiHandler *ApiHandler) handleGetReplicaSetList(
request *restful.Request, response *restful.Response) {
......
// Copyright 2015 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
k8serrors "k8s.io/kubernetes/pkg/api/errors"
client "k8s.io/kubernetes/pkg/client/unversioned"
)
// Specification for application name validation request.
type AppNameValiditySpec struct {
// Name of the application.
Name string `json:"name"`
// Namescpace of the application.
Namespace string `json:"namespace"`
}
// Describes validity of the application name.
type AppNameValidity struct {
// True when the applcation name is valid.
Valid bool `json:"valid"`
}
// Validates application name. When error is returned, name validity could not be determined.
func ValidateAppName(spec *AppNameValiditySpec, client client.Interface) (*AppNameValidity, error) {
isValidRc := false
isValidService := false
_, err := client.ReplicationControllers(spec.Namespace).Get(spec.Name)
if err != nil {
if isNotFoundError(err) {
isValidRc = true
} else {
return nil, err
}
}
_, err = client.Services(spec.Namespace).Get(spec.Name)
if err != nil {
if isNotFoundError(err) {
isValidService = true
} else {
return nil, err
}
}
return &AppNameValidity{Valid: isValidRc && isValidService}, nil
}
// Returns true when the given error is 404-NotFound error.
func isNotFoundError(err error) bool {
statusErr, ok := err.(*k8serrors.StatusError)
if !ok {
return false
}
return statusErr.ErrStatus.Code == 404
}
......@@ -193,3 +193,18 @@ backendApi.ReplicaSetPods;
* }}
*/
backendApi.Logs;
/**
* @typedef {{
* name: string,
* namespace: string
* }}
*/
backendApi.AppNameValiditySpec;
/**
* @typedef {{
* valid: boolean
* }}
*/
backendApi.AppNameValidity;
......@@ -18,6 +18,7 @@ import deployLabelDirective from './deploylabel_directive';
import deployFromFileDirective from './deployfromfile_directive';
import fileReaderDirective from './filereader_directive';
import uploadDirective from './upload_directive';
import uniqueNameDirective from './uniquename_directive';
import helpSectionModule from './helpsection/helpsection_module';
/**
......@@ -36,6 +37,7 @@ export default angular.module(
.config(stateConfig)
.directive('deployFromSettings', deployFromSettingsDirective)
.directive('deployFromFile', deployFromFileDirective)
.directive('kdUniqueName', uniqueNameDirective)
.directive('kdFileReader', fileReaderDirective)
.directive('kdUpload', uploadDirective)
.directive('kdLabel', deployLabelDirective);
......@@ -15,14 +15,19 @@ limitations under the License.
-->
<kd-help-section>
<md-input-container class="md-block" ng-model-options="{ updateOn: 'blur' }">
<md-input-container class="md-block" md-is-error="ctrl.isNameError()">
<label>App name</label>
<input ng-model="ctrl.name" required name="name">
<input ng-model="ctrl.name" name="name" namespace="ctrl.namespace" required kd-unique-name ng-model-options="{ updateOn: 'default blur', debounce: { 'default': 500, 'blur': 0 } }">
<md-progress-linear class="kd-deploy-form-name-progress" md-mode="indeterminate"
ng-show="ctrl.form.name.$pending">
</md-progress-linear>
<ng-messages for="ctrl.form.name.$error" role="alert" multiple>
<ng-message when="required">Application name is required.</ng-message>
<ng-message when="uniqueName">
Application with this name already exists within namespace <i>{{ctrl.namespace}}</i>.
</ng-message>
</ng-messages>
</md-input-container>
</md-input-container>
<kd-user-help>
An 'app' label with this value will be added to the Replica Set and Service that get deployed.
</kd-user-help>
......
// Copyright 2015 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
md-progress-linear {
&.kd-deploy-form-name-progress {
$bar-height: 2px;
clear: left;
height: $bar-height;
margin-bottom: -$bar-height;
overflow: hidden;
top: -$bar-height;
}
}
......@@ -15,6 +15,7 @@
import showNamespaceDialog from './createnamespace_dialog';
import DeployLabel from './deploylabel';
import {stateName as replicasetliststate} from 'replicasetlist/replicasetlist_state';
import {uniqueNameValidationKey} from './uniquename_directive';
// Label keys for predefined labels
const APP_LABEL_KEY = 'app';
......@@ -194,6 +195,20 @@ export default class DeployFromSettingsController {
() => { this.namespace = this.namespaces[0]; });
}
/**
* Returns true when name input should show error. This overrides default behavior to show name
* uniqueness errors even in the middle of typing.
* @return {boolean}
* @export
*/
isNameError() {
/** @type {!angular.NgModelController} */
let name = this.form['name'];
return name.$error[uniqueNameValidationKey] ||
(name.$invalid && (name.$touched || this.form.$submitted));
}
/**
* Converts array of DeployLabel to array of backend api label
* @param {!Array<!DeployLabel>} labels
......
// Copyright 2015 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/** The name of this directive. */
export const uniqueNameValidationKey = 'uniqueName';
/**
* Validates that application name is unique within the given namespace.
*
* @param {!angular.$resource} $resource
* @param {!angular.$q} $q
* @return {!angular.Directive}
* @ngInject
*/
export default function uniqueNameDirective($resource, $q) {
const namespaceParam = 'namespace';
return {
restrict: 'A',
require: 'ngModel',
scope: {
[namespaceParam]: '=',
},
link: function(scope, element, attrs, ctrl) {
/** @type {!angular.NgModelController} */
let ngModelController = ctrl;
scope.$watch(namespaceParam, () => { ctrl.$validate(); });
ngModelController.$asyncValidators[uniqueNameValidationKey] =
(name) => { return validate(name, scope[namespaceParam], $resource, $q); };
},
};
}
/**
* @param {string} name
* @param {string} namespace
* @param {!angular.$resource} resource
* @param {!angular.$q} q
*/
function validate(name, namespace, resource, q) {
let deferred = q.defer();
/** @type {!angular.Resource<!backendApi.AppNameValiditySpec>} */
let resourceClass = resource('/api/appdeployments/validate/name');
/** @type {!backendApi.AppNameValiditySpec} */
let spec = {name: name, namespace: namespace};
resourceClass.save(
spec,
/**
* @param {!backendApi.AppNameValidity} validity
*/
(validity) => {
if (validity.valid === true) {
deferred.resolve();
} else {
deferred.reject();
}
},
() => { deferred.reject(); });
return deferred.promise;
}
// Copyright 2015 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"fmt"
"k8s.io/kubernetes/pkg/api"
"k8s.io/kubernetes/pkg/client/unversioned/testclient"
"k8s.io/kubernetes/pkg/runtime"
"testing"
)
func TestValidateName(t *testing.T) {
spec := &AppNameValiditySpec{
Namespace: "foo-namespace",
Name: "foo-name",
}
cases := []struct {
spec *AppNameValiditySpec
objects []runtime.Object
expected bool
}{
{
spec,
nil,
true,
},
{
spec,
[]runtime.Object{&api.ReplicationController{}},
false,
},
{
spec,
[]runtime.Object{&api.Service{}},
false,
},
{
spec,
[]runtime.Object{&api.ReplicationController{}, &api.Service{}},
false,
},
}
for _, c := range cases {
testClient := testclient.NewSimpleFake(c.objects...)
validity, _ := ValidateAppName(c.spec, testClient)
fmt.Printf("%#v\n", validity)
if validity.Valid != c.expected {
t.Errorf("Expected %#v validity to be %#v for objects %#v, but was %#v\n",
c.spec, c.expected, c.objects, validity)
}
}
}
......@@ -15,19 +15,33 @@
import DeployFromSettingController from 'deploy/deployfromsettings_controller';
import deployModule from 'deploy/deploy_module';
import DeployLabel from 'deploy/deploylabel';
import {uniqueNameValidationKey} from 'deploy/uniquename_directive';
describe('DeployFromSettings controller', () => {
/** @type {!DeployFromSettingController} */
let ctrl;
/** @type {!angular.$resource} */
let mockResource;
/** @type {!angular.FormController} */
let form;
beforeEach(() => {
angular.mock.module(deployModule.name);
angular.mock.inject(($controller) => {
mockResource = jasmine.createSpy('$resource');
ctrl = $controller(DeployFromSettingController, {$resource: mockResource}, {namespaces: []});
form = {
$submitted: false,
name: {
$touched: false,
$invalid: false,
$error: {
[uniqueNameValidationKey]: false,
},
},
};
ctrl = $controller(
DeployFromSettingController, {$resource: mockResource}, {namespaces: [], form: form});
});
});
......@@ -161,7 +175,7 @@ describe('DeployFromSettings controller', () => {
it('should hide more options by default', () => {
// this is default behavior so no given/when
// then
expect(ctrl.isMoreOptionsEnabled()).toBeFalsy();
expect(ctrl.isMoreOptionsEnabled()).toBe(false);
});
it('should show more options after switch', () => {
......@@ -169,6 +183,44 @@ describe('DeployFromSettings controller', () => {
ctrl.switchMoreOptions();
// then
expect(ctrl.isMoreOptionsEnabled()).toBeTruthy();
expect(ctrl.isMoreOptionsEnabled()).toBe(true);
});
describe('isNameError', () => {
it('should show all errors on submit', () => {
expect(ctrl.isNameError()).toBe(false);
form.name.$invalid = true;
expect(ctrl.isNameError()).toBe(false);
form.$submitted = true;
expect(ctrl.isNameError()).toBe(true);
});
it('should show all errors when touched', () => {
expect(ctrl.isNameError()).toBe(false);
form.name.$invalid = true;
expect(ctrl.isNameError()).toBe(false);
form.name.$touched = true;
expect(ctrl.isNameError()).toBe(true);
});
it('should always show name uniqueness errors', () => {
expect(ctrl.isNameError()).toBe(false);
form.name.$error[uniqueNameValidationKey] = true;
expect(ctrl.isNameError()).toBe(true);
form.$submitted = true;
expect(ctrl.isNameError()).toBe(true);
});
});
});
// Copyright 2015 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import deployModule from 'deploy/deploy_module';
describe('DeployFromSettings controller', () => {
/** @type {function(!angular.Scope):!angular.JQLite} */
let compileFn;
/** @type {!angular.Scope} */
let scope;
/** @type {!angular.$httpBackend} */
let httpBackend;
beforeEach(() => {
angular.mock.module(deployModule.name);
angular.mock.inject(($compile, $rootScope, $httpBackend) => {
compileFn = $compile('<div kd-unique-name ng-model="name" namespace="namespace"></div>');
scope = $rootScope.$new();
httpBackend = $httpBackend;
});
});
it('should validate name asynchronosuly', () => {
scope.name = 'foo-name';
scope.namespace = 'foo-namespace';
let endpoint = httpBackend.when('POST', '/api/appdeployments/validate/name');
let elem = compileFn(scope)[0];
expect(elem.classList).toContain('ng-valid');
expect(elem.classList).not.toContain('ng-pending');
endpoint.respond({
valid: false,
});
scope.$apply();
expect(elem.classList).not.toContain('ng-valid');
expect(elem.classList).toContain('ng-pending');
httpBackend.flush();
expect(elem.classList).toContain('ng-invalid');
expect(elem.classList).not.toContain('ng-valid');
expect(elem.classList).not.toContain('ng-pending');
scope.name = 'foo-name2';
endpoint.respond({
valid: true,
});
scope.$apply();
httpBackend.flush();
expect(elem.classList).toContain('ng-valid');
});
it('should validate on namespace change', () => {
scope.name = 'foo-name';
scope.namespace = 'foo-namespace';
let elem = compileFn(scope)[0];
httpBackend.when('POST', '/api/appdeployments/validate/name').respond({
valid: false,
});
httpBackend.flush();
expect(elem.classList).not.toContain('ng-pending');
scope.namespace = 'foo-namespace2';
scope.$apply();
expect(elem.classList).toContain('ng-pending');
});
it('should treat failures as invalid name', () => {
scope.name = 'foo-name';
scope.namespace = 'foo-namespace';
let elem = compileFn(scope)[0];
httpBackend.when('POST', '/api/appdeployments/validate/name').respond(503, '');
httpBackend.flush();
expect(elem.classList).not.toContain('ng-pending');
expect(elem.classList).toContain('ng-invalid');
});
});
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册